Integration guide: Medplum, Aidbox, Oystehr
Where to draw the instrumentation boundary in three FHIR platforms — verified against the current SDK surface, gotchas called out.
Three FHIR platforms all sit close enough to the same abstraction that
one recipe covers them: draw the instrumentation boundary at the SDK's
fetch layer. None of these clients fits clinical-receipt's Level 1
instrumentFHIR — that wrapper wants a client with .baseUrl and a
swappable .fetch property, and it refuses to attach otherwise with
PARTIAL_INSTRUMENTATION_UNSAFE. Two of the three accept a custom
fetch in their constructor, so Level 2 (instrumentFHIRFetch around
globalThis.fetch) is the working path. The third — Aidbox — needs
Level 3 (the explicit fhirExtension(...).operation(...).commitResponse(...)
API) because its generated client does not expose a fetch seam.
Every claim on this page was checked against the platform's current docs and source. Where the docs and the on-disk code disagreed (Aidbox, for one), the version that matches the shipped code is what's here.
The shared pattern
Wrap standard fetch once and hand the wrapped function to whichever
constructors accept it. Everything else the SDK does — reads,
searches, single-resource writes — flows through the recorder and
lands in the same Merkle-committed receipt as your model calls,
guardrails, and human review.
import { createReceipt } from "@0xsarwagya/clinical-receipt";
import { instrumentFHIRFetch } from "@0xsarwagya/clinical-receipt/fhir";
const run = await createReceipt({
workflow: { id: "discharge-review", version: "2.4.1" },
});
const instrumentedFetch = instrumentFHIRFetch(globalThis.fetch, {
run,
baseUrl: BASE,
server: { id: "your-org-owned-id" }, // NOT the API hostname
});server.id should be a stable identifier your organization owns —
records the which store claim durably even if the URL changes.
When to drop to Level 3
Batch and transaction bundles, GraphQL queries, and any transport that doesn't decompose into individual HTTP calls don't map to one instrumented fetch. Wrap those with the explicit operation API:
import { fhirExtension } from "@0xsarwagya/clinical-receipt/fhir";
const fhir = fhirExtension(run, { server: { id: "..." } });
const op = fhir.operation({
method: "POST",
baseUrl: BASE,
path: "/",
body: transactionBundle,
});
const response = await customTransport(transactionBundle);
await op.commitResponse({ status: response.status, body: response.body });All three platforms lean on transaction bundles — plan for a Level 3 path alongside the Level 2 fetch from the start. Everything still lands in one receipt.
Medplum
SDK: @medplum/core · FHIR: R4 (typed; URL path defaults to
fhir/R4/) · Fetch seam: constructor fetch option · Primary
path: Level 2 · Level 3: batch/transaction bundles, GraphQL
MedplumClient accepts fetch in its constructor options. The seam
is real, but the shape is looser than standard fetch — Medplum
defines its own FetchLike type:
type FetchLike = (url: string, options?: any) => Promise<any>;Standard fetch satisfies it; wrapping globalThis.fetch with
instrumentFHIRFetch and passing the wrapper is fine. Do not try
to hand back a plain object with a different response shape — the
client calls .json() and reads .headers internally.
import { MedplumClient } from "@medplum/core";
const medplum = new MedplumClient({
baseUrl: BASE,
fetch: instrumentedFetch, // from the shared pattern above
});
const patient = await medplum.readResource("Patient", "123");
const bundle = await medplum.search("Observation", "patient=123");
await medplum.createResource(clinicalImpression);Method names to know
readResource(resourceType, id)— single-resource read.search(resourceType, query?)returns aBundle<T>;searchResources(...)returns a flat array. Pick intentionally — the receipt commits whatever body flows through.createResource(resource),updateResource(resource),patchResource(resourceType, id, ops),deleteResource(...).getBaseUrl(): string— a method, not a property.
Level 3 paths
executeBatch(bundle)— transaction/batch bundles.graphql(query, operationName?, variables?, options?)— note the second argument isoperationName, notvariables. Skipping this detail silently swaps the two.
Runtime and gotchas
- Same class works in the browser, Node, and Medplum Bots. Bots
receive a pre-configured
MedplumClientfrom the runtime — instrumenting the injected client is not straightforward; use Level 3 or a wrapper Bot that owns its own client. - Construction does not authenticate. Provide
accessToken, or run OAuth viastartLogin/startClientLogin. If you pass a custom asyncClientStorage,await medplum.getInitPromise()before the first call. - Cache defaults differ: 60s in browsers, 0s in Node.
Aidbox
SDK: generated client (see below) · FHIR: R4/R4B/R5/R6-ballot, per profile · Fetch seam: none documented in the generated client · Primary path: Level 3 · Level 2: possible only if you write the transport yourself
Aidbox's TypeScript story is unsettled. The official getting-started page still shows:
npm i -g @fhirschema/codegen
npx fscg generate -g typescript -p hl7.fhir.r4.core@4.0.1 -o aidbox
...but @fhirschema/codegen was archived on 2026-03-18 with a
notice pointing at @atomic-ehr/codegen (npm: @atomic-ehr/codegen)
as the maintained successor. Aidbox's docs have not migrated the
snippet as of this writing. Match your project to whichever codegen
your build actually uses; both produce a Client with the same call
shape.
// Whichever codegen produced this file:
import { Client } from "../aidbox";
const client = new Client(BASE, {
auth: {
method: "basic",
credentials: { username, password },
},
});
const patient = await client.resource.create("Patient", body);The transport gap
Neither the current Aidbox docs nor the generated client's README
documents a fetch constructor option, module-level global override,
or transport interceptor. There is no seam Level 2 can attach to
through the SDK. The two honest options:
- Use Level 3 for every operation. Model each
client.resource.*call as an explicitfhirExtension().operation()with.commitResponse(...)fed the client's return value. Verbose but the abstraction the API was designed for. - Bypass the generated client and issue raw
fetchagainst Aidbox's REST endpoints. Then wrap that fetch withinstrumentFHIRFetchand it works exactly like Medplum. You give up the codegen typing.
Pick (1) if the typing pays for the verbosity. Pick (2) if you can tolerate less typing and want one consistent instrumentation path.
Auth in production
The docs show auth: { method: "basic", credentials: {...} } — that
is the dev path. Production uses OAuth2 Client Credentials Grant
returning token_type: Bearer. Store the token, refresh it, pass it
via Authorization: Bearer <token> on whichever transport you end
up using.
Batch, transaction, and GraphQL
Aidbox supports FHIR transaction bundles (POST / with a Bundle)
and its own GraphQL endpoint (POST /$graphql). Neither has a
dedicated helper on the generated client — both are raw REST calls.
Whichever integration path you take, wrap them in Level 3.
Ottehr (via Oystehr)
SDK: @oystehr/sdk · FHIR: R4B recommended, R5 supported ·
Fetch seam: constructor fetch option · Primary path: Level 2 ·
Level 3: transaction bundles
Ottehr is the open-source, FHIR-native EHR; Oystehr is the backend it runs against ("headless EHR" — FHIR API, auth, Zambda functions). The Ottehr site declares "Powered by Oystehr." Integrating with Ottehr means instrumenting the Oystehr SDK.
import Oystehr from "@oystehr/sdk";
const oystehr = new Oystehr({
accessToken,
fetch: instrumentedFetch, // must conform to Undici fetch
});
const patient = await oystehr.fhir.create({
resourceType: "Patient",
name: [{ family: "Doe" }],
});
const bundle = await oystehr.fhir.search({
resourceType: "Observation",
params: [{ name: "patient", value: `Patient/${patient.id}` }],
});Note the create signature: a flat resource, { resourceType, ...fields }
— not { resourceType, resource: {...} }. Search takes
{ resourceType, params }.
The fetch constraint is real
Oystehr's docs are explicit:
"Optionally provide a custom fetch implementation. This must conform to the built-in node.js fetch implementation (Undici fetch)."
So the passed function must match Node's Undici-style signature.
Standard browser fetch and standard Node global fetch both
satisfy it in practice — wrapping either with instrumentFHIRFetch
works — but do not hand it a completely custom object that differs
in Response shape.
FHIR version mismatch
clinical-receipt v0.2 targets FHIR R4. Oystehr supports R4B
and R5, with R4B as the recommended default. The canonicalization
profile fhir-json-r4@1 is byte-oriented; it does not care whether
the JSON on the wire is R4, R4B, or R5. Commitments still compute
deterministically and receipts still verify integrity. What you lose
against R4B/R5 payloads is the semantic guarantee — verifyFHIR
will still return ok, but any code that reasons over resource shape
downstream of the receipt needs to know it is not looking at R4.
If you use Oystehr, validate verifyFHIR against your R4B/R5
resource shapes before relying on it end-to-end.
Auth gotcha
accessToken in the constructor is the pattern. clientId is not a
constructor field. projectId is required specifically for
developer access tokens — production M2M or user tokens don't
need it. Read the Oystehr TypeScript SDK docs
for the exact matrix.
Level 3 paths
oystehr.fhir.transaction(bundle)— transaction bundles.oystehr.fhir.batch(bundle)— batch bundles.
Both bypass the per-resource fetch and want the explicit operation API.
At a glance
| Platform | SDK | FHIR | Fetch seam | Primary path |
|---|---|---|---|---|
| Medplum | @medplum/core | R4 | constructor `fetch` (FetchLike) | Level 2 |
| Aidbox | @fhirschema/codegen (archived) → @atomic-ehr/codegen | R4/R4B/R5 | none documented | Level 3 |
| Generated client has no fetch injection point. Level 3 is the honest primary. Level 2 works only if you bypass the codegen client and issue raw fetch. | ||||
| Oystehr (Ottehr) | @oystehr/sdk | R4B / R5 | constructor `fetch` (Undici-style) | Level 2 |
| verifyFHIR is R4-shaped in v0.2 — validate against R4B/R5 payloads before relying on it. | ||||
What none of this changes
- Neither the receipt nor
verifyFHIRclaim any of these servers is truthful. Integrity is not truth; a matching commitment proves the bytes the workflow observed haven't changed, not that those bytes were clinically correct. - Record
server.idas a stable identifier your org owns, not the API hostname. Hostnames move. - v0.2 commits first-page-only searches (
pagination: "complete-first-page-only"when the server returns anextlink); multi-page consumption is a later release. Every platform on this page paginates by default. - Use ephemeral or org-held signing keys per your trust model. The receipt is one piece of the identity story, not the whole thing.