Developers / FHIR for systems / Step by step
Step by step

Send FHIR Bundles through the ESB

By the end, your integration engine posts a signed transaction Bundle of AllergyIntolerance resources, gets a transaction-response back, and the allergies appear on the resident’s record in HealthOS. Allow about an hour. The provider’s HealthOS administrator does most of the set-up in HealthOS Admin. You write one small script.

You will need

  • An integration engine, or any system that can send an HTTPS POST and compute an HMAC-SHA256. Our script uses Node.js 18 or later and nothing else.
  • A sample of the Bundle your engine produces, so the provider can map it.
  • The identifier your Bundles carry for the person (here an MRN), and a test person the provider already holds with that identifier.
  • An hour of the provider’s HealthOS administrator’s time.

Before you start

A Bundle you send to the ESB doesn’t write straight into the record. Each entry runs through a channel the provider configures: a mapping turns your resource into HealthOS fields, the person is matched on your identifier, and the result is staged. A promotion step then writes it to the record, on a schedule the provider sets. That gives the provider a place to see, check and hold back what you send.

Plan one channel per resource type. A channel promotes into one kind of record, so allergies, conditions and observations each get their own. If one Bundle has to carry several types, the provider can route them with a rule step, but the simplest start is one type.

Everything here was run against a local HealthOS with demo data, for a demo resident, Margaret Chen (MRN CHL-DEMO-002). The provider’s HealthOS is shown as https://healthos.example.org, and tokens and secrets are replaced with placeholders.

Set it up

  1. Install a FHIR connection for your engine

    Provider administrator

    The channel needs a connection to belong to. In HealthOS Admin, go to Interoperability → Marketplace, search for FHIR and click Install on FHIR R4 endpoint.

    The Connector Marketplace searched for FHIR, showing the FHIR R4 Import and FHIR R4 endpoint cards
    Use FHIR R4 endpoint. It can be both a source and a destination of a channel.

    In the set-up form, enter your engine’s FHIR base URL, set Authentication to None and Resource types to AllergyIntolerance. HealthOS calls the base URL only if the provider later has it read from your server as well, so for a push-only feed nothing is sent to it. Click Next, then Complete Setup.

    The FHIR R4 endpoint setup form with a FHIR base URL, Authentication set to None
    The top of the set-up form. The other fields are for a server HealthOS reads from, which Pull from your FHIR server covers.
  2. Create the ingress channel

    Provider administrator

    Go to Interoperability → Integration Topology and open the new connection. Click Edit configuration, which opens a draft: nothing you change goes live until you publish it. On the Ingress lane, click Enable channel, enter AllergyIntolerance as the source object and click Create channel (disabled).

    The Enable ingress dialog with AllergyIntolerance as the source object and a Create channel (disabled) button
    This makes a channel that stages what arrives for matching and promotion. It starts disabled. You turn it into a webhook in step 6.
  3. Map the resource

    Provider administrator

    The channel needs two mapping templates on the connection. The first flattens each AllergyIntolerance into HealthOS fields, and runs as a step in the channel. The second, marked as the default and set to the allergy entity, is what staging and promotion use. It maps those fields one to one and matches the person on mrn.

    From your resourceTo
    patient.identifier.valuemrn (matches the person, and is a match-on field)
    code.coding.0.codecode_value (also a match-on field)
    code.coding.0.system, code.coding.0.displaycode_system, code_display
    clinicalStatus.coding.0.code, verificationStatus.coding.0.codeclinical_status, verification_status
    type, category.0, criticalitytype, category, criticality
    reaction.0.manifestation.0.coding.0.display, reaction.0.severityreaction_manifestation, reaction_severity
    recordedDaterecorded_date

    Marking mrn and code_value as match-on fields means a second delivery of the same allergy for the same person updates it instead of adding a duplicate.

    Today the connection’s mapping editor offers person fields only, so we added these two templates through the integrations API (POST /api/v1/integrations/mappings) while the draft was open. Once published, the ingress Mapping block shows them:

    The ingress mapping list showing the template FHIR AllergyIntolerance to CDM fields with twelve source paths and the HealthOS field each maps to
    The flattening template. Source columns are dotted paths into your resource.
  4. Turn on promotion

    Provider administrator

    Click the No match → create block on the ingress lane. On the Allergies template, switch Auto-promote on and enter a schedule such as */15 * * * *. Leave Only matched on, so a record that doesn’t match a person waits in staging rather than being promoted.

    The Allergies template's promotion settings: CDM entity allergy, Auto-promote on, Only matched on, Create + update, Write directly, schedule */15 * * * *
    After publishing. While the draft is open this panel still shows the live values, so don’t be surprised when a switch seems to spring back.
  5. Publish the configuration

    Provider administrator

    Click Review & publish in the draft banner, check the list of changes and click Publish. Publishing records a version the provider can roll back to.

    The Publish configuration dialog listing 2 field mappings added, with a Publish v2 button
    Publishing the two templates. Changing the promotion settings is a second, smaller draft.
  6. Make the channel a FHIR webhook

    Provider administrator

    Go to Interoperability → Integration Flows and click the edit icon on the channel. In Channel settings, give it a name, set Trigger to Webhook and switch Enabled on. Click + Transform, leave it on Field mapping and enter the flattening template’s ID. Paste a sample Bundle into Fields from a sample payload and click Derive fields to check the paths you mapped exist. Then click Save changes.

    The Edit channel page with trigger Webhook, Enabled on, a Field mapping transform step, and a sample Bundle with derived field paths
    The transform step takes the template’s ID, not its name. The destination block names the connection: its records are staged there for promotion.
  7. Send the URL and the secret to the vendor

    Provider administrator

    Open the channel again. The source block now shows two URLs and the signing secret (click the eye to reveal it). Send the vendor the FHIR transaction endpoint, which ends in /fhir, and the secret, over a channel you both trust.

    The webhook source block with a Webhook URL, a FHIR transaction endpoint URL, signing instructions and a hidden signing secret
    The token in each URL identifies this channel and so this organisation. We have replaced the host and hidden the secret.

Send a Bundle

  1. Build a transaction Bundle

    You

    Each entry carries one resource and a POST request. The person is identified by the MRN in patient.identifier:

    {
      "resourceType": "Bundle",
      "type": "transaction",
      "entry": [
        {
          "fullUrl": "urn:uuid:4f6c2a10-8d1e-4b7a-9c55-0e2f1d3a7b01",
          "resource": {
            "resourceType": "AllergyIntolerance",
            "clinicalStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", "code": "active" }] },
            "verificationStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification", "code": "confirmed" }] },
            "type": "allergy",
            "category": ["medication"],
            "criticality": "high",
            "code": { "coding": [{ "system": "http://snomed.info/sct", "code": "764146007", "display": "Penicillin" }] },
            "patient": { "identifier": { "system": "http://ns.electronichealth.net.au/id/hospital/mrn", "value": "CHL-DEMO-002" } },
            "recordedDate": "2026-10-17T09:30:00+10:00",
            "reaction": [{ "manifestation": [{ "coding": [{ "system": "http://snomed.info/sct", "code": "271807003", "display": "Rash" }] }],
                           "severity": "moderate" }]
          },
          "request": { "method": "POST", "url": "AllergyIntolerance" }
        },
        { "fullUrl": "urn:uuid:4f6c2a10-8d1e-4b7a-9c55-0e2f1d3a7b02", "resource": { "resourceType": "AllergyIntolerance", … "Latex" … },
          "request": { "method": "POST", "url": "AllergyIntolerance" } }
      ]
    }
  2. Sign it and post it

    You

    The signature is a hex HMAC-SHA256, keyed with the secret, over the timestamp, a full stop and the exact bytes of the body. Sign what you send: if your engine re-serialises the JSON after signing, the signature won’t match.

    // sign-and-send.mjs: sign a body with the channel's secret and POST it.
    //   node sign-and-send.mjs <url> <secret> <file> [content-type] [idempotency-key]
    import { createHmac } from 'node:crypto';
    import { readFileSync } from 'node:fs';
    
    const [url, secret, file, contentType = 'application/json', idempotencyKey] = process.argv.slice(2);
    const body = readFileSync(file, 'utf8');
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const signature = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
    
    const headers = {
      'Content-Type': contentType,
      'X-HealthOS-Timestamp': timestamp,
      'X-HealthOS-Signature': signature,
    };
    if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
    
    const res = await fetch(url, { method: 'POST', headers, body });
    console.log(res.status, res.statusText);
    console.log(await res.text());
    node sign-and-send.mjs \
      https://healthos.example.org/api/v1/flow-webhooks/{token}/fhir \
      "$SIGNING_SECRET" bundle.json application/fhir+json

    The same request with curl and openssl, if that suits your engine better:

    ts=$(date +%s)
    sig=$( { printf '%s.' "$ts"; cat bundle.json; } | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex | sed 's/^.* //')
    curl -X POST "https://healthos.example.org/api/v1/flow-webhooks/{token}/fhir" \
      -H 'Content-Type: application/fhir+json' \
      -H "X-HealthOS-Timestamp: $ts" -H "X-HealthOS-Signature: $sig" \
      --data-binary @bundle.json

    What came back for us, one response per entry in the order you sent them:

    HTTP/1.1 200 OK
    Content-Type: application/fhir+json; charset=utf-8
    
    {
      "resourceType": "Bundle",
      "type": "transaction-response",
      "timestamp": "2026-10-18T02:05:01.682Z",
      "entry": [
        { "response": { "status": "200 OK" } },
        { "response": { "status": "200 OK" } }
      ]
    }

    Each entry runs as its own unit of the channel, so a transaction here is not all-or-nothing: an entry already delivered stays delivered if a later one fails, and its own response.status tells you which. Up to 1,000 entries and 10 MiB go in one Bundle.

  3. Watch the run

    Provider administrator

    On Integration Flows, the channel shows its last run. Click History and expand the run to see each entry as its own source, fhir-entry-0000 onwards.

    The Integration Flows list with the channel Allergies from the results engine, trigger Webhook, enabled, last run success
    Run history for the channel: one webhook run, 2 in, 2 out, expanded to two sources fhir-entry-0000 and fhir-entry-0001, both success
    One run, two units. A failed entry shows its error here.

    On the connection’s overview, Staged data lists what each entry staged and whether the person matched. After the next promotion run it shows what each was promoted into.

    Staged data for the connection: two staged objects, each 1 record, matching 1/1, promoted to allergy
    Matching 1/1 means the MRN found the person. Promoted: allergy means it is on the record.

Check it worked

  • The response is a transaction-response with 200 OK for every entry.
  • The run in History shows as many sources as you sent entries, all success.
  • Staged data shows each record matched. After the promotion schedule has run, it shows allergy under Promoted.
  • In HealthOS, open the resident and go to Person → Allergies:
Margaret Chen's record in HealthOS, Allergies tab, listing Penicillin (moderate, rash, active) and Latex (mild, itching, active)
Both allergies from the Bundle, on the demo resident’s record. They also appear in the allergy banner on her chart.

If something goes wrong

These are the answers we saw, or that the channel is written to give.

What you seeWhyWhat to do
401 OperationOutcome, code security, “Invalid signature”, or a message about the timestamp windowThe signature doesn’t match the body, the secret is wrong, or the timestamp is more than five minutes old or a minute aheadSign the exact bytes you send, with the timestamp you send. Check your clock
404 OperationOutcome, code not-found, “Unknown webhook”The token in the URL is wrong, or was rotated more than 24 hours agoAsk the provider for the current URL
400 OperationOutcome, code invalidThe body isn’t a transaction or batch Bundle, or isn’t valid JSONSend other Bundle types to the plain URL, without /fhir
An entry answered 405Its request method is GET, HEAD, PATCH or DELETE. Only POST and PUT runSend only creates and updates through the channel
An entry answered 422 or 500A step in the channel refused it, or it failedThe provider sees the reason in History
Staged, Matching 0/1, never promotedNo person in the organisation has that MRNCheck the identifier. The provider can link the record from the match queue
Matched but not promotedAuto-promote is off, it has no schedule, or the change is still in an unpublished draftTurn it on with a schedule and publish

The FHIR endpoint does not use Idempotency-Key today: a retried Bundle runs again. With mrn and code_value as match-on fields, a retry updates the same allergy instead of adding a second one.

Send us a sample Bundle

We’ll map it with the provider against a test organisation, so the first live Bundle lands where it should.