main*
📝fhir-api-healthcare-lessons.md
📅October 9, 20249 min read

FHIR APIs in Practice: Lessons from Healthcare Interoperability

#healthcare#FHIR#API#interoperability#HIPAA

Why Healthcare Tech Is Different

Healthcare APIs aren't like other APIs. The stakes are higher:

ConcernRegular APIHealthcare API
Data leakageEmbarrassingPotentially life-threatening
ComplianceGDPR checkboxHIPAA criminal liability
AuthenticationOAuthOAuth + SMART on FHIR + IHE profiles
DowntimeRevenue lossPatient care impact

I spent two years building FHIR-compliant APIs for a health tech company. Here's what I learned.

FHIR: The Standard That's Also a Framework

FHIR (Fast Healthcare Interoperability Resources) is both a data model and a REST API specification. It defines:

  • Resources: Domain objects (Patient, Observation, Medication, etc.)
  • Interactions: REST operations (read, search, create, update)
  • Profiles: Conformance constraints for specific use cases
  • Extensions: Custom fields that don't break compliance

The Resource Model

Everything in FHIR is a Resource. Each has:

{
  "resourceType": "Patient",
  "id": "example",
  "meta": {
    "versionId": "1",
    "lastUpdated": "2024-10-10T12:00:00Z",
    "profile": ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient"]
  },
  "identifier": [{
    "system": "http://hospital.org/mrn",
    "value": "12345"
  }],
  "name": [{
    "family": "Smith",
    "given": ["John"],
    "use": "official"
  }],
  "birthDate": "1980-01-01",
  "gender": "male"
}

The resourceType field is mandatory. Everything else depends on the resource and profile.

Core Resources

ResourcePurposeCommon Interactions
PatientPerson receiving careRead, Search, Update
PractitionerHealthcare providerRead, Search
OrganizationHospital, clinic, practiceRead, Search
ObservationLab results, vitalsRead, Search, Create
MedicationRequestPrescriptionRead, Create, Update
DiagnosticReportTest resultsRead, Search
EncounterCare visitRead, Search

Building a FHIR Server

The Capability Statement

Every FHIR server must expose its capabilities:

// fhir/capability-statement.ts
import { CapabilityStatement } from 'fhir/r4';
 
export const capabilityStatement: CapabilityStatement = {
  resourceType: 'CapabilityStatement',
  status: 'active',
  date: '2024-10-10',
  kind: 'instance',
  fhirVersion: '4.0.1',
  format: ['json', 'xml'],
  rest: [{
    mode: 'server',
    security: {
      cors: true,
      service: [{
        coding: [{
          system: 'http://terminology.hl7.org/CodeSystem/restful-security-service',
          code: 'SMART-on-FHIR'
        }]
      }]
    },
    resource: [
      {
        type: 'Patient',
        interaction: [
          { code: 'read' },
          { code: 'search-type' },
          { code: 'update' }
        ],
        searchParam: [
          { name: 'identifier', type: 'token', definition: 'http://hl7.org/fhir/SearchParameter/Patient-identifier' },
          { name: 'name', type: 'string', definition: 'http://hl7.org/fhir/SearchParameter/Patient-name' },
          { name: 'birthdate', type: 'date', definition: 'http://hl7.org/fhir/SearchParameter/Patient-birthdate' }
        ]
      }
    ]
  }]
};

This tells clients exactly what your server supports. No guessing, no "read the docs."

Search Implementation

FHIR search is powerful and complex. Parameters can be:

  • Simple: GET /Patient?name=Smith
  • Token: GET /Patient?identifier=http://hospital.org/mrn|12345
  • Date: GET /Observation?date=gt2024-01-01&date=lt2024-12-31
  • Composite: GET /Observation?patient=Patient/123&code=http://loinc.org|8867-4
// fhir/search-handler.ts
import { Bundle, Resource } from 'fhir/r4';
 
interface SearchParams {
  [key: string]: string | string[];
}
 
export async function searchResources<T extends Resource>(
  resourceType: string,
  params: SearchParams
): Promise<Bundle<T>> {
  const query = buildFhirQuery(params);
  const results = await db.query(query);
  
  return {
    resourceType: 'Bundle',
    type: 'searchset',
    total: results.total,
    entry: results.rows.map((row: any) => ({
      resource: row as T,
      search: { mode: 'match' }
    }))
  };
}
 
function buildFhirQuery(params: SearchParams): string {
  // FHIR has complex search syntax
  // _id=123 → exact match
  // name=Smith → contains
  // identifier=system|value → composite
  // date=gt2024-01-01 → greater than
  
  const conditions: string[] = [];
  
  for (const [key, value] of Object.entries(params)) {
    if (key === '_id') {
      conditions.push(`id = '${value}'`);
    } else if (key.includes(':')) {
      // Chained search: patient:identifier=123
      conditions.push(handleChainedSearch(key, value));
    } else {
      conditions.push(handleStandardSearch(key, value));
    }
  }
  
  return `SELECT * FROM ${params.resourceType} WHERE ${conditions.join(' AND ')}`;
}

The Bundle Structure

FHIR operations return Bundles—containers for multiple resources:

{
  "resourceType": "Bundle",
  "type": "searchset",
  "total": 42,
  "link": [
    { "relation": "self", "url": "https://api.example.com/Patient?name=Smith" },
    { "relation": "next", "url": "https://api.example.com/Patient?name=Smith&_page=2" }
  ],
  "entry": [
    {
      "resource": { "resourceType": "Patient", "id": "123", ... }
    },
    {
      "resource": { "resourceType": "Patient", "id": "124", ... }
    }
  ]
}

Pagination is built-in. The link array provides HATEOAS navigation.

HIPAA Considerations

PHI in Logs

Health data is Protected Health Information (PHI). Logging it is a potential breach.

// WRONG - logging PHI
console.log('Patient data:', patient);
 
// RIGHT - logging metadata only
console.log('Patient accessed:', patient.id, 'by', userId);
 
// BETTER - structured audit logging
auditLog.info({
  action: 'READ',
  resourceType: 'Patient',
  resourceId: patient.id,
  actor: userId,
  timestamp: new Date().toISOString()
  // No actual patient data
});

Minimum Necessary Principle

HIPAA requires accessing only the minimum data necessary for a task.

// FHIR supports this via _elements parameter
GET /Patient/123?_elements=name,birthDate
 
// Server should only return those fields
{
  "resourceType": "Patient",
  "id": "123",
  "name": [{ "family": "Smith", "given": ["John"] }],
  "birthDate": "1980-01-01"
  // No SSN, no address, no other identifiers
}

Audit Trails

Every access must be logged. FHIR provides OperationOutcome for this:

// fhir/audit-logger.ts
export async function logAccess(
  userId: string,
  resourceType: string,
  resourceId: string,
  action: 'READ' | 'CREATE' | 'UPDATE' | 'DELETE'
): Promise<void> {
  await auditDb.insert({
    timestamp: new Date(),
    userId,
    resourceType,
    resourceId,
    action,
    ipAddress: getCurrentIp(),
    userAgent: getUserAgent()
  });
}

Data Transformation: The Real Challenge

Healthcare data comes from legacy systems. HL7 v2, CCDs, proprietary formats. Transforming to FHIR is 80% of the work.

HL7 v2 to FHIR

// transform/hl7v2-to-fhir.ts
import { Patient } from 'fhir/r4';
 
export function transformHL7v2Patient(hl7Message: string): Patient {
  const segments = parseHL7v2(hl7Message);
  const pid = segments.find(s => s.name === 'PID');
  
  if (!pid) throw new Error('No PID segment found');
  
  return {
    resourceType: 'Patient',
    identifier: [{
      system: 'http://hospital.org/mrn',
      value: pid.fields[3] // MRN in PID-4
    }],
    name: [{
      family: pid.fields[5], // Last name in PID-6
      given: pid.fields[4]?.split(' ') // First + Middle names
    }],
    birthDate: formatHL7Date(pid.fields[7]), // DOB in PID-8
    gender: mapGender(pid.fields[8]) // Sex in PID-9
  };
}
 
// HL7 v2 date: 19800101 → FHIR date: 1980-01-01
function formatHL7Date(hl7Date: string): string {
  if (!hl7Date || hl7Date.length < 8) return '';
  return `${hl7Date.slice(0, 4)}-${hl7Date.slice(4, 6)}-${hl7Date.slice(6, 8)}`;
}

Handling Missing Data

Healthcare data is messy. Required fields are often missing.

// transform/required-field-handling.ts
export function requireField<T>(value: T | undefined, fieldName: string): T {
  if (value === undefined || value === null || value === '') {
    // FHIR OperationOutcome for validation errors
    throw new FhirError({
      severity: 'error',
      code: 'required',
      details: { text: `Missing required field: ${fieldName}` }
    });
  }
  return value;
}
 
// Or use data absent reason
export function absentOrValue<T>(value: T | undefined): T | { dataAbsentReason: string } {
  if (value === undefined || value === null) {
    return { dataAbsentReason: 'unknown' };
  }
  return value;
}

SMART on FHIR: OAuth for Healthcare

SMART on FHIR extends OAuth2 for healthcare contexts:

// auth/smart-launch.ts
interface SmartLaunchParams {
  client_id: string;
  scope: string;  // "patient/*.read launch/patient"
  state: string;
  redirect_uri: string;
  aud: string;  // Must match the FHIR server URL
}
 
export async function handleSmartLaunch(params: SmartLaunchParams): Promise<string> {
  // Validate scope requests
  const requestedScopes = params.scope.split(' ');
  
  if (!requestedScopes.includes('launch/patient')) {
    throw new Error('SMART launch requires launch/patient scope');
  }
  
  // The EHR provides patient context
  const patientId = await getPatientContext();
  
  // Generate authorization URL
  return buildAuthUrl({
    ...params,
    patient_id: patientId  // Context passed through
  });
}

Common SMART scopes:

  • patient/*.read - Read access to all patient data
  • patient/Observation.read - Read observations only
  • launch/patient - EHR launches with patient context
  • offline_access - Refresh tokens for background access

Validation with StructureDefinition

FHIR profiles let you define custom validation rules:

// profiles/us-core-patient.ts
import { StructureDefinition } from 'fhir/r4';
 
export const usCorePatient: StructureDefinition = {
  resourceType: 'StructureDefinition',
  url: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient',
  name: 'US Core Patient Profile',
  status: 'active',
  kind: 'resource',
  type: 'Patient',
  baseDefinition: 'http://hl7.org/fhir/StructureDefinition/Patient',
  differential: {
    element: [
      {
        path: 'Patient.identifier',
        min: 1,  // At least one identifier required
        mustSupport: true
      },
      {
        path: 'Patient.name',
        min: 1,  // At least one name required
        mustSupport: true
      },
      {
        path: 'Patient.gender',
        binding: {
          strength: 'required',
          valueSet: 'http://hl7.org/fhir/ValueSet/administrative-gender'
        }
      }
    ]
  }
};

Validate resources against profiles:

// validation/profile-validator.ts
import { validateResource } from 'fhir-validator';
 
const result = await validateResource(patient, usCorePatient);
 
if (!result.valid) {
  console.log('Validation errors:', result.issues);
  // [{ path: 'Patient.identifier', message: 'Minimum cardinality not met' }]
}

Lessons from Production

1. Always Validate Before Transform

I once transformed 100,000 records before realizing the source system had a different date format. Now I validate a sample first:

const sample = await getSampleRecords(100);
const validation = await validateAll(sample);
if (!validation.passing) {
  throw new Error(`Sample validation failed: ${validation.errors}`);
}

2. Test with Real Data Shapes

Unit tests with synthetic data don't catch real issues. I have a test suite with anonymized production data samples:

tests/
├── fixtures/
│   ├── hl7v2-samples/
│   │   ├── admission.msg
│   │   ├── discharge.msg
│   │   └── transfer.msg
│   └── fhir-expectations/
│       ├── patient-expected.json
│       └── observation-expected.json
└── transforms/
    └── hl7v2-to-fhir.test.ts

3. Breadcrumbs in Logs Save Hours

// When things go wrong, you need context
logger.error('Transform failed', {
  sourceType: 'HL7v2',
  messageType: message.type,
  segmentCount: segments.length,
  failedSegment: segment.name,
  failedField: fieldIndex,
  rawValue: rawValue,
  error: error.message
});

Without this context, debugging production issues is impossible.

4. Pagination Is Not Optional

FHIR requires servers to support _count and pagination. Large result sets will crash clients without it:

// FHIR recommends max 100 resources per page
const DEFAULT_PAGE_SIZE = 100;
 
async function handleSearch(params: SearchParams): Promise<Bundle> {
  const count = Math.min(params._count || DEFAULT_PAGE_SIZE, 1000);
  const offset = (params._page || 0) * count;
  
  // Always cap results
  const results = await db.query({ ...params, limit: count, offset });
  
  return buildBundle(results, {
    self: buildPageUrl(params, offset),
    next: results.hasMore ? buildPageUrl(params, offset + count) : undefined
  });
}

Resources

Conclusion

Healthcare APIs taught me:

  • Standards matter - FHIR's strictness prevents integration nightmares
  • Security isn't optional - PHI in logs is a breach, period
  • Transform is the work - The API layer is 20%, data transformation is 80%
  • Validate everything - Trust nothing from external systems

The rigor required in healthcare made me better at building all APIs, not just healthcare ones.


Building healthcare APIs? I'd love to compare notes. Find me on Twitter.