Designing a lab API developers actually finish integrating
Most lab APIs die in the integration phase. Here is the schema, idempotency and webhook design we landed on after watching teams try to ship lab results into real products.
Every digital health team eventually hits the same wall: the product is ready, the clinician is signed, the marketing site is live — and the lab integration is three months behind. Not because lab testing is conceptually hard, but because the interface was designed for fax machines and then wrapped in SOAP.
Here is the design we landed on, and the reasoning behind each decision.
Start from the result, not from the order
Most lab APIs are order-centric: you POST an order, then poll a status endpoint, then discover the result arrives as a PDF with a different identifier scheme. Builders spend weeks reconciling identifiers.
We inverted it. The contract that matters is the result payload, so it is fixed and versioned first:
{
"order_id": "ord_9f2c",
"status": "final",
"results": [
{ "loinc": "58410-2", "analyte": "Hemoglobin", "value": 13.4,
"unit": "g/dL", "ref_range": "12.0-15.5", "flag": "normal" }
]
}
Every field is mandatory in the schema even when the value is null. loinc is always the LOINC code, never a lab-specific code. flag is one of a closed set: normal, low, high, critical_low, critical_high. If a lab cannot supply a field, it is null and the payload says so explicitly — silent omissions are what break integrations.
Idempotency is not optional
Order creation is the one call where a network retry can cost real money and create a duplicate draw. Requiring Idempotency-Key on every write means a client can retry aggressively — which is exactly what you want during a flaky mobile upload — without risking a second kit shipment.
The rule we enforce: same key plus same body returns the original response with 200; same key with a different body returns 409. That second case catches the bug where a client reuses a key across different patients, which is far more dangerous than a duplicate order.
Webhooks, with signatures and replay protection
Polling is a tax on both sides. Results arrive when they arrive, so push them:
POST /your-endpoint
X-Pml-Signature: t=1730294400,v1=8f2c...
X-Pml-Event: order.result_final
Two details matter more than the rest of the webhook design combined.
First, sign the raw body with a timestamp, and reject deliveries older than five minutes. Without the timestamp, a captured payload can be replayed forever.
Second, retry with exponential backoff for 24 hours and expose a replay endpoint in the dashboard. Webhook consumers go down; the recovery path should not require a support ticket.
Model collection as its own state machine
The order lifecycle is not linear, and pretending it is produces nonsense statuses like processing. We expose the collection leg explicitly:
created → awaiting_payment → kit_shipped → sample_in_transit → received_at_lab → in_analysis → result_pending_review → final
A test that needs a redraw moves back to kit_shipped with a recollect_reason. Products can then show honest copy — “your sample arrived at the lab” beats a spinner labelled “processing”.
Ship the code systems, not just the endpoint
Two vocabularies cause most of the friction: LOINC for what was measured, and UCUM for the unit. Expose both as data:
GET /v1/tests?category=metabolic
GET /v1/units
If your API returns mg/dL in one payload and mgdl in another, every consumer writes a normalisation table. Publish the canonical list once and validate on write.
FHIR is an export format, not the internal model
FHIR R4 DiagnosticReport is the right answer for interoperability with EHRs, and a poor answer for your internal schema: it is verbose, nested, and its optionality makes it hard to validate.
Keep a compact internal model and generate FHIR on demand:
GET /v1/orders/{id}/results?format=fhir
The same final result should render to JSON, signed PDF, FHIR R4 and HL7 v2 ORU^R01. One source of truth, four projections — never four independent pipelines that drift.
Sandbox that returns realistic data in seconds
A sandbox that returns {"ok": true} teaches nothing. Ours returns complete synthetic results within seconds, with plausible values, reference ranges and flags, including the occasional critical value so teams can build the escalation path before a real patient triggers it.
The test of a good lab API is simple: a developer should go from signup to a rendered result payload in under an hour, without talking to anyone. If that is not true, the problem is the interface, not the developer.