FHIR APIs in Practice: Lessons from Healthcare Interoperability
Why Healthcare Tech Is Different
Healthcare APIs aren't like other APIs. The stakes are higher:
| Concern | Regular API | Healthcare API |
|---|---|---|
| Data leakage | Embarrassing | Potentially life-threatening |
| Compliance | GDPR checkbox | HIPAA criminal liability |
| Authentication | OAuth | OAuth + SMART on FHIR + IHE profiles |
| Downtime | Revenue loss | Patient 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
| Resource | Purpose | Common Interactions |
|---|---|---|
| Patient | Person receiving care | Read, Search, Update |
| Practitioner | Healthcare provider | Read, Search |
| Organization | Hospital, clinic, practice | Read, Search |
| Observation | Lab results, vitals | Read, Search, Create |
| MedicationRequest | Prescription | Read, Create, Update |
| DiagnosticReport | Test results | Read, Search |
| Encounter | Care visit | Read, 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 datapatient/Observation.read- Read observations onlylaunch/patient- EHR launches with patient contextoffline_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
- HL7 FHIR Specification - The source of truth
- SMART on FHIR - OAuth for healthcare
- FHIR Testing Tools - Test servers and validators
- ONC Certification - US certification requirements
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.