FHIR & HL7 Integration: Healthcare APIs in .NET
Build healthcare integrations with FHIR R4 and HL7 v2 in .NET. Covers FHIR resource model, SMART on FHIR authentication, Firely SDK, HL7 parsing, patient data pipelines, and real-world integration patterns.
Healthcare Integration Landscape
Healthcare data exchange is uniquely complex. Patient data must be:
- Interoperable: systems from different vendors must share data
- Secure: HIPAA/GDPR compliance, patient consent
- Accurate: clinical decisions depend on correct data
- Auditable: full history of who accessed what and when
Two dominant standards define how healthcare systems communicate:
| Standard | Type | Used For | |----------|------|---------| | HL7 v2 | Pipe-delimited messages | Legacy hospital systems (ADT, lab results, orders) | | FHIR R4 | REST + JSON/XML | Modern APIs, mobile apps, cloud integrations |
Most healthcare organisations run both β FHIR for new integrations, HL7 v2 for legacy systems (Epic, Cerner, Meditech).
FHIR Fundamentals
FHIR (Fast Healthcare Interoperability Resources) is a modern REST API standard for healthcare data.
Core concepts
- Resource: a unit of healthcare data β Patient, Observation, Medication, Encounter, etc.
- Server: exposes a FHIR-compliant REST API
- Bundle: a container for multiple resources
- Profile: a constraint on a base resource (e.g., UK Core Patient profile)
- Extension: adding custom fields to a resource
FHIR REST API
GET /Patient/123 β read a patient
POST /Patient β create a patient (server assigns ID)
PUT /Patient/123 β update/replace a patient
PATCH /Patient/123 β partial update
DELETE /Patient/123 β delete (creates a tombstone)
GET /Patient?family=Smith β search
GET /Patient/123/_history β version history
POST / β batch/transaction bundleFHIR Patient resource (JSON)
{
"resourceType": "Patient",
"id": "example-patient-1",
"meta": {
"versionId": "1",
"lastUpdated": "2025-04-13T09:30:00Z",
"profile": ["https://fhir.hl7.org.uk/StructureDefinition/UKCore-Patient"]
},
"identifier": [
{
"system": "https://fhir.nhs.uk/Id/nhs-number",
"value": "9000000009"
}
],
"name": [
{
"use": "official",
"family": "Smith",
"given": ["John", "Paul"]
}
],
"gender": "male",
"birthDate": "1985-03-15",
"address": [
{
"use": "home",
"line": ["42 High Street"],
"city": "Manchester",
"postalCode": "M1 1AA",
"country": "GB"
}
],
"telecom": [
{ "system": "phone", "value": "+44 7700 900000", "use": "mobile" },
{ "system": "email", "value": "john.smith@example.com" }
],
"generalPractitioner": [
{ "reference": "Organization/gp-practice-001" }
]
}Setup β Firely SDK (.NET)
The official .NET FHIR SDK is Firely (formerly Hl7.Fhir.Net).
dotnet add package Hl7.Fhir.R4
dotnet add package Hl7.Fhir.Validation.R4using Hl7.Fhir.Model;
using Hl7.Fhir.Rest;
using Hl7.Fhir.Serialization;
// Create a Patient resource
var patient = new Patient
{
Id = "patient-001",
Identifier = new List<Identifier>
{
new Identifier("https://fhir.nhs.uk/Id/nhs-number", "9000000009")
},
Name = new List<HumanName>
{
new HumanName().WithGiven("John").AndFamily("Smith")
},
Gender = AdministrativeGender.Male,
BirthDate = "1985-03-15",
Active = true,
};
// Serialize to JSON
var serializer = new FhirJsonSerializer(new SerializerSettings { Pretty = true });
string json = serializer.SerializeToString(patient);
// Deserialize from JSON
var parser = new FhirJsonParser();
var parsedPatient = parser.Parse<Patient>(json);
// Access structured data
string nhsNumber = parsedPatient.Identifier
.FirstOrDefault(i => i.System == "https://fhir.nhs.uk/Id/nhs-number")?.Value ?? "";FHIR Client
var client = new FhirClient("https://r4.smarthealthit.org");
// Read a patient
Patient patient = await client.ReadAsync<Patient>("Patient/87a339d0-8cae-418e-89c7-8651e6aab3c6");
// Search patients by NHS number
Bundle results = await client.SearchAsync<Patient>(new[]
{
("identifier", "https://fhir.nhs.uk/Id/nhs-number|9000000009")
});
foreach (var entry in results.Entry)
{
var p = (Patient)entry.Resource;
Console.WriteLine($"{p.Name[0].FamilyElement} - {p.BirthDate}");
}
// Create a new patient
var created = await client.CreateAsync(patient);
Console.WriteLine($"Created: {created.Id}");
// Update patient
patient.Active = false;
var updated = await client.UpdateAsync(patient);
// Get patient with observations
var observation = new Observation
{
Status = ObservationStatus.Final,
Code = new CodeableConcept("http://loinc.org", "8867-4", "Heart rate"),
Subject = new ResourceReference("Patient/patient-001"),
Effective = new FhirDateTime(DateTimeOffset.UtcNow),
Value = new Quantity(72, "beats/min", "http://unitsofmeasure.org")
};
await client.CreateAsync(observation);
// Bundle transaction (atomic: all succeed or all fail)
var bundle = new Bundle { Type = Bundle.BundleType.Transaction };
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = "urn:uuid:patient-temp-id",
Resource = patient,
Request = new Bundle.RequestComponent
{
Method = Bundle.HTTPVerb.POST,
Url = "Patient"
}
});
bundle.Entry.Add(new Bundle.EntryComponent
{
Resource = observation,
Request = new Bundle.RequestComponent
{
Method = Bundle.HTTPVerb.POST,
Url = "Observation"
}
});
var transactionResult = await client.TransactionAsync(bundle);SMART on FHIR Authentication
SMART on FHIR is the standard OAuth2 profile for FHIR APIs.
// backend-to-backend: client credentials flow
public class FhirAuthService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly SmartSettings _settings;
public async Task<string> GetAccessTokenAsync()
{
var client = _httpClientFactory.CreateClient();
// Discover FHIR server capabilities
var capabilityStatement = await client.GetFromJsonAsync<JsonDocument>(
$"{_settings.FhirBaseUrl}/metadata");
var tokenUrl = ExtractTokenUrl(capabilityStatement);
// Request token
var response = await client.PostAsync(tokenUrl, new FormUrlEncodedContent(new[]
{
("grant_type", "client_credentials"),
("client_id", _settings.ClientId),
("client_secret", _settings.ClientSecret),
("scope", "system/Patient.read system/Observation.read"),
}));
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>();
return tokenResponse!.AccessToken;
}
}
// Register authenticated FHIR client
builder.Services.AddHttpClient<IFhirService, FhirService>(client =>
{
client.BaseAddress = new Uri(settings.FhirBaseUrl);
}).AddHttpMessageHandler<FhirAuthHandler>();
public class FhirAuthHandler : DelegatingHandler
{
private readonly FhirAuthService _auth;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var token = await _auth.GetAccessTokenAsync();
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, ct);
}
}Building a FHIR Service
public interface IFhirPatientService
{
Task<PatientDto?> GetByNhsNumberAsync(string nhsNumber, CancellationToken ct = default);
Task<IReadOnlyList<ObservationDto>> GetObservationsAsync(string patientId, CancellationToken ct = default);
Task<string> CreateOrUpdatePatientAsync(PatientDto dto, CancellationToken ct = default);
}
public class FhirPatientService : IFhirPatientService
{
private readonly FhirClient _client;
private readonly IMapper _mapper;
private readonly ILogger<FhirPatientService> _logger;
public async Task<PatientDto?> GetByNhsNumberAsync(string nhsNumber, CancellationToken ct = default)
{
try
{
var bundle = await _client.SearchAsync<Patient>(new[]
{
("identifier", $"https://fhir.nhs.uk/Id/nhs-number|{nhsNumber}")
});
var patient = bundle.Entry
.Select(e => e.Resource as Patient)
.FirstOrDefault(p => p is not null);
return patient is null ? null : _mapper.Map<PatientDto>(patient);
}
catch (FhirOperationException ex) when (ex.Status == System.Net.HttpStatusCode.NotFound)
{
return null;
}
}
public async Task<IReadOnlyList<ObservationDto>> GetObservationsAsync(
string patientId, CancellationToken ct = default)
{
var bundle = await _client.SearchAsync<Observation>(new[]
{
("patient", patientId),
("_sort", "-date"),
("_count", "50"),
});
return bundle.Entry
.Select(e => e.Resource as Observation)
.Where(o => o is not null)
.Select(o => _mapper.Map<ObservationDto>(o!))
.ToList();
}
public async Task<string> CreateOrUpdatePatientAsync(PatientDto dto, CancellationToken ct = default)
{
// Search for existing patient by NHS number
var existing = await GetByNhsNumberAsync(dto.NhsNumber, ct);
var fhirPatient = _mapper.Map<Patient>(dto);
if (existing is null)
{
var created = await _client.CreateAsync(fhirPatient);
_logger.LogInformation("Created FHIR Patient {Id} for NHS {NhsNumber}", created.Id, dto.NhsNumber);
return created.Id!;
}
else
{
fhirPatient.Id = existing.FhirId;
await _client.UpdateAsync(fhirPatient);
_logger.LogInformation("Updated FHIR Patient {Id}", existing.FhirId);
return existing.FhirId;
}
}
}HL7 v2 Message Parsing
HL7 v2 is the dominant standard in hospital systems. Messages look like:
MSH|^~\&|SENDING_APP|SENDING_FAC|RECV_APP|RECV_FAC|20250413093000||ADT^A01|MSG001|P|2.5
EVN|A01|20250413093000
PID|1||9000000009^^^NHS^NH||Smith^John^Paul||19850315|M|||42 High Street^^Manchester^^M1 1AA^GBR|||||||||||||||
PV1|1|I|Ward7^Bed12^Room2||||12345^Jones^Sarah^Dr^MD|||MED||||ADM|||||||||||||||||||||||||20250413|||Parsing with NHapiTools
dotnet add package NHapi.Model.V25
dotnet add package NHapi.Baseusing NHapi.Base.Parser;
using NHapi.Model.V25.Message;
using NHapi.Model.V25.Segment;
public class Hl7MessageParser
{
private readonly PipeParser _parser = new();
public AdtAdmissionDto? ParseAdt(string rawMessage)
{
try
{
var message = _parser.Parse(rawMessage);
if (message is not ADT_A01 adt) return null;
var pid = adt.PID;
var pv1 = adt.PV1;
return new AdtAdmissionDto
{
// PID-3: patient identifier list
NhsNumber = pid.GetPatientIdentifierList(0).IDNumber.Value,
// PID-5: patient name
FamilyName = pid.GetPatientName(0).FamilyName.Surname.Value,
GivenName = pid.GetPatientName(0).GivenName.Value,
// PID-7: date of birth
DateOfBirth = DateTime.ParseExact(
pid.DateTimeOfBirth.Time.Value, "yyyyMMdd", null),
// PID-8: sex
Gender = pid.AdministrativeSex.Value,
// PV1: visit information
WardCode = pv1.AssignedPatientLocation.PointOfCare.Value,
AdmissionDate = DateTime.ParseExact(
pv1.AdmitDateTime.Time.Value, "yyyyMMddHHmmss", null),
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse HL7 message");
return null;
}
}
// Parse lab result (ORU^R01)
public LabResultDto? ParseLabResult(string rawMessage)
{
var message = _parser.Parse(rawMessage);
if (message is not ORU_R01 oru) return null;
var pid = oru.GetPATIENT_RESULT(0).PATIENT.PID;
var obr = oru.GetPATIENT_RESULT(0).GetORDER_OBSERVATION(0).OBR;
var obx = oru.GetPATIENT_RESULT(0).GetORDER_OBSERVATION(0).GetOBSERVATION(0).OBX;
return new LabResultDto
{
NhsNumber = pid.GetPatientIdentifierList(0).IDNumber.Value,
TestCode = obr.UniversalServiceIdentifier.Identifier.Value,
TestName = obr.UniversalServiceIdentifier.Text.Value,
Result = obx.GetObservationValue(0).Data?.ToString() ?? "",
Units = obx.Units.Identifier.Value,
ReferenceRange = obx.ReferencesRange.Value,
AbnormalFlag = obx.GetAbnormalFlags(0).Value,
ResultDate = DateTime.ParseExact(obx.DateTimeOfTheObservation.Time.Value, "yyyyMMddHHmmss", null),
};
}
// Build HL7 ACK (acknowledgement)
public string BuildAck(string messageControlId, string ackCode = "AA")
{
var ack = new ACK();
ack.MSH.SendingApplication.NamespaceID.Value = "INTEGRATION_ENGINE";
ack.MSH.MessageType.MessageCode.Value = "ACK";
ack.MSH.MessageControlID.Value = Guid.NewGuid().ToString("N")[..20];
ack.MSA.AcknowledgmentCode.Value = ackCode;
ack.MSA.MessageControlID.Value = messageControlId;
return _parser.Encode(ack);
}
}HL7 MLLP Listener (TCP)
HL7 v2 messages are sent over MLLP (Minimal Lower Layer Protocol) β a simple TCP framing protocol.
// MLLP framing: VT + message + FS + CR
// VT = 0x0B (vertical tab)
// FS = 0x1C (file separator)
// CR = 0x0D (carriage return)
public class MllpListener : BackgroundService
{
private const byte VT = 0x0B;
private const byte FS = 0x1C;
private const byte CR = 0x0D;
private readonly ILogger<MllpListener> _logger;
private readonly Hl7MessageParser _parser;
private readonly IServiceScopeFactory _scopeFactory;
private TcpListener? _tcpListener;
protected override async Task ExecuteAsync(CancellationToken ct)
{
_tcpListener = new TcpListener(IPAddress.Any, 2575);
_tcpListener.Start();
_logger.LogInformation("MLLP listener started on port 2575");
while (!ct.IsCancellationRequested)
{
var client = await _tcpListener.AcceptTcpClientAsync(ct);
_ = HandleClientAsync(client, ct); // handle each connection concurrently
}
}
private async Task HandleClientAsync(TcpClient client, CancellationToken ct)
{
using var stream = client.GetStream();
var buffer = new byte[65536];
var messageBuffer = new List<byte>();
while (!ct.IsCancellationRequested)
{
int bytesRead = await stream.ReadAsync(buffer, ct);
if (bytesRead == 0) break;
for (int i = 0; i < bytesRead; i++)
{
if (buffer[i] == VT)
{
messageBuffer.Clear();
}
else if (buffer[i] == FS)
{
var rawMessage = Encoding.UTF8.GetString(messageBuffer.ToArray());
await ProcessMessageAsync(rawMessage, stream, ct);
messageBuffer.Clear();
}
else
{
messageBuffer.Add(buffer[i]);
}
}
}
}
private async Task ProcessMessageAsync(string rawMessage, NetworkStream stream, CancellationToken ct)
{
string messageControlId = "UNKNOWN";
try
{
messageControlId = ExtractMessageControlId(rawMessage);
_logger.LogInformation("Received HL7 message: {MessageControlId}", messageControlId);
// Route by message type
var msgType = ExtractMessageType(rawMessage);
using var scope = _scopeFactory.CreateScope();
switch (msgType)
{
case "ADT^A01": // admission
case "ADT^A08": // update patient info
var adtDto = _parser.ParseAdt(rawMessage);
if (adtDto is not null)
{
var service = scope.ServiceProvider.GetRequiredService<IPatientSyncService>();
await service.SyncAdmissionAsync(adtDto, ct);
}
break;
case "ORU^R01": // lab result
var labDto = _parser.ParseLabResult(rawMessage);
if (labDto is not null)
{
var service = scope.ServiceProvider.GetRequiredService<ILabResultService>();
await service.ProcessLabResultAsync(labDto, ct);
}
break;
}
// Send ACK
var ack = _parser.BuildAck(messageControlId, "AA");
var ackBytes = Encoding.UTF8.GetBytes($"{(char)VT}{ack}{(char)FS}{(char)CR}");
await stream.WriteAsync(ackBytes, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing HL7 message {MessageControlId}", messageControlId);
var nak = _parser.BuildAck(messageControlId, "AE"); // AE = Application Error
var nakBytes = Encoding.UTF8.GetBytes($"{(char)VT}{nak}{(char)FS}{(char)CR}");
await stream.WriteAsync(nakBytes, ct);
}
}
}HL7 to FHIR Mapping Pipeline
A common requirement: receive HL7 v2 from a hospital, transform to FHIR, write to a FHIR server.
public class Hl7ToFhirPipeline
{
private readonly Hl7MessageParser _hl7Parser;
private readonly IFhirPatientService _fhirService;
private readonly IMapper _mapper;
public async Task ProcessAdmissionAsync(string hl7Message, CancellationToken ct)
{
// 1. Parse HL7
var admission = _hl7Parser.ParseAdt(hl7Message);
if (admission is null)
throw new InvalidOperationException("Failed to parse ADT message");
// 2. Map to FHIR Patient
var patientDto = new PatientDto
{
NhsNumber = admission.NhsNumber,
FamilyName = admission.FamilyName,
GivenNames = new[] { admission.GivenName },
DateOfBirth = admission.DateOfBirth,
Gender = MapGender(admission.Gender),
};
// 3. Upsert to FHIR server
string fhirId = await _fhirService.CreateOrUpdatePatientAsync(patientDto, ct);
// 4. Create FHIR Encounter for the admission
var encounter = new Encounter
{
Status = Encounter.EncounterStatus.InProgress,
Class = new Coding("http://terminology.hl7.org/CodeSystem/v3-ActCode", "IMP", "inpatient encounter"),
Subject = new ResourceReference($"Patient/{fhirId}"),
Period = new Period { Start = admission.AdmissionDate.ToString("yyyy-MM-ddTHH:mm:ssZ") },
Location = new List<Encounter.LocationComponent>
{
new Encounter.LocationComponent
{
Location = new ResourceReference { Display = $"{admission.WardCode}" }
}
}
};
await _fhirService.CreateEncounterAsync(encounter, ct);
}
}Azure FHIR Service
Azure Health Data Services includes a managed FHIR server.
// appsettings.json
{
"FhirServer": {
"BaseUrl": "https://your-workspace.fhir.azurehealthcareapis.com",
"TenantId": "your-tenant-id",
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret"
}
}
// Program.cs β register with Azure credential
builder.Services.AddSingleton<FhirClient>(sp =>
{
var settings = sp.GetRequiredService<IOptions<FhirSettings>>().Value;
var credential = new ClientSecretCredential(
settings.TenantId, settings.ClientId, settings.ClientSecret);
var httpClient = new HttpClient(new BearerTokenHandler(credential,
"https://healthcareapis.azure.com/.default"));
return new FhirClient(settings.BaseUrl, httpClient);
});FHIR Subscription (Webhooks)
FHIR Subscriptions notify your system when data changes on the FHIR server.
{
"resourceType": "Subscription",
"status": "active",
"reason": "Notify on new observations",
"criteria": "Observation?patient=Patient/123",
"channel": {
"type": "rest-hook",
"endpoint": "https://your-api.com/fhir/webhooks",
"payload": "application/fhir+json",
"header": ["Authorization: Bearer {token}"]
}
}// Webhook handler
[HttpPost("/fhir/webhooks")]
public async Task<IActionResult> HandleFhirNotification(
[FromBody] Bundle bundle,
CancellationToken ct)
{
foreach (var entry in bundle.Entry)
{
if (entry.Resource is Observation obs)
{
await _observationProcessor.ProcessAsync(obs, ct);
}
}
return Ok();
}Audit and Compliance
FHIR mandates an audit trail. Every access to patient data must be logged (HIPAA, GDPR).
public class FhirAuditService
{
private readonly FhirClient _client;
public async Task LogAccessAsync(
string userId, string patientId, string action, string resourceType)
{
var auditEvent = new AuditEvent
{
Type = new Coding("http://terminology.hl7.org/CodeSystem/audit-event-type", "rest"),
Recorded = DateTimeOffset.UtcNow,
Outcome = AuditEvent.AuditEventOutcome.N0, // success
Agent = new List<AuditEvent.AgentComponent>
{
new AuditEvent.AgentComponent
{
Who = new ResourceReference($"Practitioner/{userId}"),
Requestor = true,
Network = new AuditEvent.NetworkComponent
{
Address = "192.168.1.1",
Type = AuditEvent.AuditEventAgentNetworkType.N2,
}
}
},
Entity = new List<AuditEvent.EntityComponent>
{
new AuditEvent.EntityComponent
{
What = new ResourceReference($"Patient/{patientId}"),
Role = new Coding("http://terminology.hl7.org/CodeSystem/object-role", "1"),
}
},
};
await _client.CreateAsync(auditEvent);
}
}Production Architecture
Hospital Systems (Epic/Cerner)
β HL7 v2 over MLLP (TCP 2575)
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Integration Engine (.NET) β
β ββββββββββββββββ ββββββββββββββββ βββββββββββββββ β
β β MLLP ListenerββββΊβ HL7 Parser ββββΊβ FHIR Mapper β β
β β (Port 2575) β β β β β β
β ββββββββββββββββ ββββββββββββββββ ββββββββ¬βββββββ β
β β β
β ββββββββββββββββββββββββββββββββββββββββββββββΌβββββββ β
β β Service Bus / Queue β β
β β (decouple ingestion from processing) β β
β ββββββββββββββββββββββββββββββββββββββββββββββ¬βββββββ β
β β β
β ββββββββββββββββββββββββββββββββββββββββββββββΌβββββββ β
β β FHIR Transform & Publish Worker β β
β ββββββββββββββββββββββββββββββββββββββββββββββ¬βββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββΌβββββββββββ
β
βΌ
Azure Health Data Services
(FHIR R4 Server)
β
βββββββββββββββββββΌβββββββββββββββββββββ
βΌ βΌ βΌ
Analytics Clinical Apps Patient Portal
(Power BI, Synapse) (Web/Mobile) (SMART on FHIR)What to Learn Next
- Observability & Reliability: add structured logging and monitoring to your integration
- Azure Data Factory: build ETL pipelines for healthcare analytics
- Security & Compliance: GDPR, data encryption, role-based access for clinical data
Enjoyed this article?
Explore the Integration Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.