Nirmitee.io
FHIRInteroperabilityEHR Integration

FHIR Bundle Explained (R4): searchset, transaction, batch, document and message, with Real JSON

July 22, 202621 min readUpdated Sep 24, 2026
Written by
Jitendra Choudhary
Jitendra Choudhary

CTO & Co-Founder

CTO & Co-Founder at Nirmitee.io. Architects healthcare integrations across FHIR, SMART on FHIR, ABDM and NHCX, writing from production experience taking hospital software from sandbox to go-live.

FHIR Bundle Explained (R4): searchset, transaction, batch, document and message, with Real JSON

A FHIR Bundle is the resource that carries a set of other resources in one payload, and its type decides the rules: a searchset returns search results, a transaction writes resources as one atomic unit, a batch runs independent actions, and document and message Bundles package clinical content for exchange. Every FHIR API you call answers in Bundles, so how your code reads and writes them decides whether a pipeline is correct or quietly loses data.

This guide covers all nine R4 (4.0.1) Bundle types. The JSON below was captured from requests we ran against the public HAPI FHIR R4 test server, then trimmed for length. Every rule is checked against the R4 specification pages for Bundle, the RESTful API and Search. It is written for engineering leads doing EHR integration who need to page through results, write linked resources safely and know what a server supports before building on it.

Key takeaways

  • Bundle.type is required, has nine values in R4, and controls which entry elements are allowed: search, request or response.
  • In a searchset, Bundle.total counts matches only, never includes, and it is optional. The public HAPI server left it out until we sent _total=accurate.
  • Page by following the next link exactly as the server returns it. Page links are opaque, and includes can repeat from one page to the next.
  • A transaction is all or nothing and a failed one returns a single OperationOutcome. A batch returns HTTP 200 with a status per entry, so partial failure is normal.
  • Link new resources with urn:uuid: fullUrls and put ifNoneExist on creates so a retry cannot duplicate data.
  • Never assume a server accepts transaction or batch. Read rest.interaction in its CapabilityStatement, then test with a read-only batch.

What is a FHIR Bundle?

A FHIR Bundle is a container resource for moving or storing a group of resources together. R4 uses it for search results, version history, messages, clinical documents and multi-resource writes. Unlike contained resources, every resource in a Bundle keeps its own identity and can also exist on its own on a server.

That separates a Bundle from contained resources, which only mean something inside their parent, and from List, Group and Composition, which reference resources and carry business meaning. Bundle.type is 1..1 with a required binding, and through invariants bdl-1 to bdl-4 it controls which entry elements may appear, which is where most validator errors come from. New to the resource model? Start with what FHIR is and how it works.

What are the FHIR Bundle types in R4?

R4 defines nine Bundle types: searchset, history, transaction, transaction-response, batch, batch-response, document, message and collection. Two carry read results, four are the request and response halves of multi-action calls, and three package resources for exchange or storage. The table shows direction, typical use and the rule that matters most.

Bundle.typeDirectionUsed forKey rules
searchsetServer to clientResults of a search or an operationOnly type allowed to carry entry.search (bdl-2). total allowed (bdl-1).
historyServer to clientResults of _history on an instance, type or systemEvery entry has request and response (bdl-3, bdl-4). Oldest versions last.
transactionClient to serverAtomic write of linked resourcesentry.request required. All entries succeed or all fail.
transaction-responseServer to clientResult of a successful transactionentry.response required, one per request entry, same order.
batchClient to serverIndependent reads or writes in one HTTP callentry.request required. No interdependencies between entries.
batch-responseServer to clientPer-entry results of a batchMay mix success and failure statuses.
documentSender to receiver, or storedClinical documents such as discharge summariesComposition first (bdl-11), identifier system and value (bdl-9), timestamp (bdl-10).
messageSender to receiverEvent-driven messagingMessageHeader first (bdl-12).
collectionAnyAd hoc packages, test fixtures, file dropsNo processing rules beyond persistence.

Anatomy of a FHIR Bundle: which elements matter

A Bundle has a handful of top-level elements (type, identifier, timestamp, total, link, signature) and a list of entries. Each entry can hold a fullUrl and a resource, plus only the metadata its Bundle type allows: search details in searchsets, request details in writes and history, and response details in results.

ElementCard.What it holdsWhen it appears
type1..1One of the nine codes aboveAlways
identifier0..1Persistent id that survives copying between serversRequired for documents (bdl-9)
timestamp0..1When the Bundle was assembledRequired for documents (bdl-10)
total0..1Number of match entries across all pagesOnly searchset and history (bdl-1)
link0..*relation plus url: self, first, previous, next, lastMostly searchset and history
entry.fullUrl0..1Absolute URL or urn:uuid: identity of the resourceMust not be version-specific (bdl-8); unique unless versions differ (bdl-7)
entry.resource0..1The resource itselfRequired unless the entry has a request or response (bdl-5)
entry.search0..1mode (match, include, outcome) and scoreSearchset only (bdl-2)
entry.request0..1method, url, ifNoneMatch, ifModifiedSince, ifMatch, ifNoneExistRequired in batch, transaction and history (bdl-3)
entry.response0..1status, location, etag, lastModified, outcomeRequired in batch-response, transaction-response and history (bdl-4)

The fullUrl rules deserve a second read. Outside transactions and batches, every entry needs a fullUrl, and it is the resource's identity rather than a versioned link, so it never contains /_history/. A resource without a server identity gets a urn:uuid: value. When a reader resolves a reference, the spec says to look inside the Bundle by fullUrl first and only then try the URL externally. If two entries match, the reference is ambiguous.

How does a FHIR searchset Bundle work?

A searchset Bundle is what a server returns for a search. Entries with search.mode of match satisfy your criteria, include entries were added by _include or _revinclude, and outcome entries carry warnings. Results arrive in pages, and the only correct way to the next page is the server's next link.

Here is a real search. We asked the public HAPI R4 server for two Encounters per page, with each Encounter's subject Patient included:

curl -H "Accept: application/fhir+json" \
  "https://hapi.fhir.org/baseR4/Encounter?_include=Encounter:subject&_count=2"

The response, trimmed (resource metadata, narrative and demographics removed):

{
  "resourceType": "Bundle",
  "id": "b95c0b08-ac46-4507-9685-db3d0b9348c1",
  "type": "searchset",
  "link": [
    { "relation": "self",
      "url": "https://hapi.fhir.org/baseR4/Encounter?_count=2&_include=Encounter%3Asubject" },
    { "relation": "next",
      "url": "https://hapi.fhir.org/baseR4?_getpages=b95c0b08-ac46-4507-9685-db3d0b9348c1&_getpagesoffset=2&_count=2&_pretty=true&_include=Encounter%3Asubject&_bundletype=searchset" }
  ],
  "entry": [
    {
      "fullUrl": "https://hapi.fhir.org/baseR4/Encounter/137202491",
      "resource": {
        "resourceType": "Encounter",
        "id": "137202491",
        "status": "finished",
        "class": { "code": "OPD" },
        "subject": { "reference": "Patient/137202428" }
      },
      "search": { "mode": "match" }
    },
    {
      "fullUrl": "https://hapi.fhir.org/baseR4/Encounter/137202496",
      "resource": {
        "resourceType": "Encounter",
        "id": "137202496",
        "status": "in-progress",
        "class": {
          "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
          "code": "EMER"
        },
        "subject": { "reference": "Patient/137202485" }
      },
      "search": { "mode": "match" }
    },
    {
      "fullUrl": "https://hapi.fhir.org/baseR4/Patient/137202485",
      "resource": {
        "resourceType": "Patient",
        "id": "137202485",
        "gender": "male"
      },
      "search": { "mode": "include" }
    }
  ]
}

Four details in this one response break production code:

  • There is no total. It is optional and _total is only a hint. With _total=accurate this server reported 17,629 matching Encounters when we ran it. When present, total counts matches across all pages, never includes, and it is not the page size.
  • Two matches, one include. The first Encounter's subject, Patient/137202428, returned 410 Gone on a direct read. The spec says a referenced resource that cannot be retrieved is simply left out, with no error.
  • The next link is opaque. HAPI's is a server-side cursor (_getpages plus an offset). The Search page says clients must use the server supplied links to move between pages.
  • Includes repeat across pages. Page 2 held two matches and two includes, and Patient/137202485 was on page 1 too. Each page should carry the includes for its own matches, so deduplicate includes by fullUrl.

_count asks for a page size, and the server may return fewer entries than you asked for, so a short page is not proof of the last page. Only a missing next link means you are done. _count=0 is treated like _summary=count. A search can start as a POST, but follow-up pages are GET requests to the links. A minimal paging loop:

import requests

def fetch_all(url, headers):
    matches, includes, warnings = [], {}, []
    while url:
        bundle = requests.get(url, headers=headers, timeout=60).json()
        for entry in bundle.get("entry", []):
            mode = entry.get("search", {}).get("mode", "match")
            if mode == "match":
                matches.append(entry["resource"])
            elif mode == "include":
                includes[entry["fullUrl"]] = entry["resource"]
            else:
                warnings.append(entry.get("resource"))
        url = next((l["url"] for l in bundle.get("link", [])
                    if l["relation"] == "next"), None)
    return matches, includes, warnings

For the query side (chained parameters, _revinclude costs and sort order), see the 10 FHIR search patterns developers get wrong.

Transaction vs batch: what is the difference?

A transaction Bundle is all or nothing: every entry succeeds, or the server rejects the whole Bundle and changes nothing. A batch Bundle processes each entry on its own, returns HTTP 200 even when some entries fail, and reports a status per entry. Use a transaction for linked writes and a batch for independent work.

Behaviortransactionbatch
AtomicityAll actions succeed or fail togetherEach entry is treated as its own interaction
HTTP status200 on success; 400 or 500 type on failure200 if the batch was processed, whatever the entry outcomes
Response bodytransaction-response Bundle, or one OperationOutcome on failurebatch-response Bundle with a status per entry
References between new entriesAllowed, including circular; the server rewrites themNon-conformant
Conditional references (Patient?identifier=...)Allowed, and only hereNot allowed
Processing orderDELETE, POST, PUT or PATCH, GET or HEAD, then conditional referencesSame order, but entries must not depend on each other
Best forPatient, Encounter and Observations written as one eventMany unrelated reads, or independent updates

Batch partial failure is easy to miss because the HTTP layer reports success. We sent three reads in one batch, and one asked for a Patient that does not exist:

{
  "resourceType": "Bundle",
  "type": "batch",
  "entry": [
    { "request": { "method": "GET", "url": "Patient/137202485" } },
    { "request": { "method": "GET", "url": "Patient/does-not-exist-bdl-guide" } },
    { "request": { "method": "GET", "url": "Encounter?subject=Patient/137202485&_count=1" } }
  ]
}

The server answered HTTP 200. Inside, trimmed:

{
  "resourceType": "Bundle",
  "type": "batch-response",
  "entry": [
    { "resource": { "resourceType": "Patient", "id": "137202485" },
      "response": { "status": "200 OK" } },
    { "response": {
        "status": "404 Not Found",
        "outcome": {
          "resourceType": "OperationOutcome",
          "issue": [ { "severity": "error", "code": "exception",
            "diagnostics": "HAPI-2001: Resource Patient/does-not-exist-bdl-guide is not known" } ]
        } } },
    { "resource": { "resourceType": "Bundle", "type": "searchset", "total": 2 },
      "response": { "status": "200 OK" } }
  ]
}

A client that checks only the outer status records the missing Patient as fetched. The search entry came back as a nested searchset Bundle.

A transaction behaves the opposite way. We sent one that created a Patient and an Observation whose subject was a conditional reference, Patient?identifier=https://example.org/fhir/mrn|NO-SUCH-MRN-BDL. This server does not accept inline match URLs, so it rejected the whole Bundle with HTTP 400:

{
  "resourceType": "OperationOutcome",
  "issue": [ {
    "severity": "error",
    "code": "processing",
    "diagnostics": "HAPI-2282: Inline match URLs are not supported on this server. Cannot process reference: \"Patient?identifier=https://example.org/fhir/mrn|NO-SUCH-MRN-BDL\""
  } ]
}

A follow-up search for the Patient's family name returned a total of 0, so the valid Patient entry was rolled back too. Sent as a batch, the same two entries produced 201 Created for the Patient and 400 Bad Request for the Observation, leaving a Patient with no Observation. That is the whole trade-off in one experiment.

How to build a FHIR transaction Bundle with urn:uuid and conditional create

Give each new resource a temporary urn:uuid: fullUrl, point references at those values, and set request.ifNoneExist on each create so a retry cannot duplicate data. The server assigns real ids, rewrites every matching reference in the Bundle, and returns the new locations in a transaction-response.

R4 fixes the processing order so the outcome never depends on the order of entries:

  1. Process any DELETE interactions.
  2. Process any POST interactions.
  3. Process any PUT or PATCH interactions.
  4. Process any GET or HEAD interactions.
  5. Resolve any conditional references.

If resource identities overlap in steps 1 to 3, the transaction fails. Here is the Bundle we sent (synthetic data, example identifier systems):

{
  "resourceType": "Bundle",
  "type": "transaction",
  "entry": [
    {
      "fullUrl": "urn:uuid:b97f0c95-47ca-45f0-b6a4-6765811cdaf5",
      "resource": {
        "resourceType": "Patient",
        "identifier": [ { "system": "https://example.org/fhir/mrn", "value": "BDL-fc1816" } ],
        "name": [ { "family": "Testpatient", "given": [ "Bundle" ] } ],
        "gender": "female",
        "birthDate": "1990-01-01"
      },
      "request": {
        "method": "POST",
        "url": "Patient",
        "ifNoneExist": "identifier=https://example.org/fhir/mrn|BDL-fc1816"
      }
    },
    {
      "fullUrl": "urn:uuid:fda820b5-18ff-4286-8cef-e5047b3eaa41",
      "resource": {
        "resourceType": "Encounter",
        "identifier": [ { "system": "https://example.org/fhir/visit", "value": "V-fc1816" } ],
        "status": "finished",
        "class": {
          "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
          "code": "AMB"
        },
        "subject": { "reference": "urn:uuid:b97f0c95-47ca-45f0-b6a4-6765811cdaf5" },
        "period": { "start": "2026-07-20T09:00:00Z", "end": "2026-07-20T09:30:00Z" }
      },
      "request": {
        "method": "POST",
        "url": "Encounter",
        "ifNoneExist": "identifier=https://example.org/fhir/visit|V-fc1816"
      }
    },
    {
      "fullUrl": "urn:uuid:10c78423-d6a9-471f-b97a-3632af16f0a4",
      "resource": {
        "resourceType": "Observation",
        "identifier": [ { "system": "https://example.org/fhir/obs", "value": "HR-fc1816" } ],
        "status": "final",
        "category": [ { "coding": [ {
          "system": "http://terminology.hl7.org/CodeSystem/observation-category",
          "code": "vital-signs" } ] } ],
        "code": { "coding": [ {
          "system": "http://loinc.org", "code": "8867-4", "display": "Heart rate" } ] },
        "subject": { "reference": "urn:uuid:b97f0c95-47ca-45f0-b6a4-6765811cdaf5" },
        "encounter": { "reference": "urn:uuid:fda820b5-18ff-4286-8cef-e5047b3eaa41" },
        "effectiveDateTime": "2026-07-20T09:10:00Z",
        "valueQuantity": {
          "value": 72, "unit": "beats/minute",
          "system": "http://unitsofmeasure.org", "code": "/min" }
      },
      "request": {
        "method": "POST",
        "url": "Observation",
        "ifNoneExist": "identifier=https://example.org/fhir/obs|HR-fc1816"
      }
    }
  ]
}
curl -X POST "https://hapi.fhir.org/baseR4" \
  -H "Content-Type: application/fhir+json" \
  -H "Prefer: return=minimal" \
  --data @transaction.json

The first run returned HTTP 200 and this transaction-response (trimmed; HAPI also attaches an informational OperationOutcome to each entry):

{
  "resourceType": "Bundle",
  "type": "transaction-response",
  "entry": [
    { "response": { "status": "201 Created", "location": "Patient/138554300/_history/1", "etag": "1" } },
    { "response": { "status": "201 Created", "location": "Encounter/138554301/_history/1", "etag": "1" } },
    { "response": { "status": "201 Created", "location": "Observation/138554302/_history/1", "etag": "1" } }
  ]
}

Sending the identical Bundle a second time returned:

{
  "resourceType": "Bundle",
  "type": "transaction-response",
  "entry": [
    { "response": { "status": "200 OK", "location": "Patient/138554300/_history/1", "etag": "1" } },
    { "response": { "status": "200 OK", "location": "Encounter/138554301/_history/1", "etag": "1" } },
    { "response": { "status": "200 OK", "location": "Observation/138554302/_history/1", "etag": "1" } }
  ]
}

Each conditional create found one match, so the server skipped the create and returned 200 OK with the existing location. The R4 rule: no match creates, one match returns 200 OK, multiple matches return 412 Precondition Failed. Reading the Observation back shows the rewrite: subject Patient/138554300, encounter Encounter/138554301. R4 marks conditional create, update, patch and delete as trial use, and not every server implements them.

Servers rewrite matching values in references, resource ids, uri, url, oid and uuid elements, and narrative href or src attributes, but not canonical elements. The Observation here is a minimal vital sign; our FHIR Observation resource guide covers what US Core expects.

For existing resources, ifMatch makes an update version-aware and a conditional update puts search criteria in the URL. These request elements are illustrative:

[
  { "method": "PUT", "url": "Patient/138554300", "ifMatch": "W/\"1\"" },
  { "method": "PUT", "url": "Patient?identifier=https://example.org/fhir/mrn|BDL-fc1816" }
]

A conditional update with no match creates, with one match updates, and with multiple matches returns 412. The spec names the use case: a stateless client such as an interface engine that does not track server ids. That is how HL7 v2 feeds typically load through engines like Mirth Connect. Our HL7v2 to FHIR R4 Bundle mapping guide shows the mapping tables, and building a FHIR R4 server from scratch covers the server side.

What goes in a FHIR document Bundle and a message Bundle?

A document Bundle is an immutable clinical document: a Composition must be the first entry, and the Bundle needs an identifier with a system and value plus a timestamp. A message Bundle must start with a MessageHeader that names the event. Neither is executed like a transaction when you store it on a FHIR server.

Document Bundles

Rules from the R4 Documents page and the Bundle invariants:

  • Composition is the first entry (bdl-11). Bundle.identifier has a system and value (bdl-9) and is never reused. Bundle.timestamp is set (bdl-10).
  • Resources referenced from the Composition's subject, encounter, author, attester, custodian, event detail and section author, focus and entries SHALL be included.
  • Nothing else goes in except a Binary stylesheet and Provenance, and the assembled document is immutable.

An illustrative skeleton, not captured from a server:

{
  "resourceType": "Bundle",
  "identifier": {
    "system": "urn:ietf:rfc:3986",
    "value": "urn:uuid:0c3151bd-1cbf-4d64-b04d-cd9187a4c6e0"
  },
  "type": "document",
  "timestamp": "2026-07-20T10:00:00Z",
  "entry": [
    {
      "fullUrl": "urn:uuid:5d1a6c7e-2f0b-4d8e-9a51-3c2b1e0f7a10",
      "resource": {
        "resourceType": "Composition",
        "status": "final",
        "type": { "coding": [ {
          "system": "http://loinc.org", "code": "18842-5", "display": "Discharge summary" } ] },
        "subject": { "reference": "urn:uuid:8e2f4b6a-1c3d-4e5f-8a9b-0c1d2e3f4a5b" },
        "date": "2026-07-20T10:00:00Z",
        "author": [ { "reference": "urn:uuid:3a4b5c6d-7e8f-4a1b-9c2d-e3f4a5b6c7d8" } ],
        "title": "Discharge summary"
      }
    },
    {
      "fullUrl": "urn:uuid:8e2f4b6a-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
      "resource": { "resourceType": "Patient" }
    },
    {
      "fullUrl": "urn:uuid:3a4b5c6d-7e8f-4a1b-9c2d-e3f4a5b6c7d8",
      "resource": { "resourceType": "Practitioner" }
    }
  ]
}

In US Core, clinical notes are exposed through DocumentReference rather than as document Bundles, which changes how you retrieve them. Our guide to FHIR DocumentReference for clinical notes covers that path.

Message Bundles

From the R4 Messaging page:

  • MessageHeader is the first entry (bdl-12) and names the event. Responses are message Bundles whose MessageHeader quotes the request id in response.identifier.
  • A resend keeps its MessageHeader.id, but a Bundle.id is never reused.
  • Transport is out of scope (file, HTTP, MLLP, queues). On a REST server, $process-message is what processes a message.

What does a FHIR history Bundle contain?

A history Bundle lists versions of one resource, a resource type or a whole server, newest first, including deletes. Each entry carries a request showing the interaction that produced that version and a response with its status and ETag, so a subscriber can tell creates from updates and replay changes in order.

Captured from GET https://hapi.fhir.org/baseR4/Patient/137202485/_history?_count=2, trimmed:

{
  "resourceType": "Bundle",
  "type": "history",
  "total": 12,
  "link": [
    { "relation": "self",
      "url": "https://hapi.fhir.org/baseR4/Patient/137202485/_history?_count=2" },
    { "relation": "next",
      "url": "https://hapi.fhir.org/baseR4/Patient/137202485/_history?_count=2&_offset=2" }
  ],
  "entry": [
    {
      "fullUrl": "https://hapi.fhir.org/baseR4/Patient/137202485",
      "resource": { "resourceType": "Patient", "id": "137202485", "meta": { "versionId": "12" } },
      "request": { "method": "PUT", "url": "Patient/137202485/_history/12" },
      "response": { "status": "200 OK", "etag": "W/\"12\"" }
    },
    {
      "fullUrl": "https://hapi.fhir.org/baseR4/Patient/137202485",
      "resource": { "resourceType": "Patient", "id": "137202485", "meta": { "versionId": "11" } },
      "request": { "method": "PUT", "url": "Patient/137202485/_history/11" },
      "response": { "status": "200 OK", "etag": "W/\"11\"" }
    }
  ]
}

The repeated fullUrl is allowed only in history Bundles (bdl-7). Deletes appear as DELETE requests with no resource, and conditional operations are recorded as plain updates and deletes. Filter with _since or _at and page with next links. Servers SHOULD accept a history Bundle POSTed as a transaction or batch, which enables replication, though original transaction boundaries are lost. For push-based change feeds, see our guide to real-time pipelines with FHIR Subscriptions.

How big can a FHIR Bundle be?

The R4 Bundle specification does not define a maximum size or entry count. Limits come from each server's configuration, gateway timeouts and memory. Design page sizes and transaction sizes around what your target server accepts, and move population-scale reads to Bulk Data export instead of paging through millions of searchset entries.

  • Ask for less. _elements and _summary shrink resources, but _summary=text cannot be combined with _include, and _elements does not apply to included resources. Never write subsetted resources back.
  • Avoid wildcard and :iterate includes. The Search page warns they may slow responses significantly.
  • Keep each transaction to one clinical event. A failure then rolls back one visit, not a day of feed data.
  • Send Prefer: return=minimal on writes when locations are enough. Our entries still returned status, location and ETag.
  • Use Bulk Data for population reads. $export returns NDJSON files instead of Bundles. Our Bulk FHIR export guide for Epic, Oracle Health and athenahealth covers it.

How to check whether an EHR supports transaction or batch

Fetch the server's CapabilityStatement from [base]/metadata and read rest.interaction. The system-level codes transaction and batch declare support. Then confirm with a harmless batch of GET requests, because declarations can be incomplete, and a sandbox statement does not guarantee what a customer's production server allows.

curl -s -H "Accept: application/fhir+json" "https://hapi.fhir.org/baseR4/metadata" \
  | jq '.rest[] | {mode, interaction: [.interaction[]?.code]}'

We ran that check against four public R4 endpoints. The results show why the CapabilityStatement is a starting point and not the final word:

ServerMetadata URLrest.interaction declaredWhat we observed
HAPI FHIR public test serverhapi.fhir.org/baseR4/metadatatransaction, history-systemAlso processed our batch, which it does not declare
SMART Health IT R4 sandboxr4.smarthealthit.org/metadatahistory-system, transactionMetadata only; no writes sent
Epic public R4 sandboxfhir.epic.com/.../FHIR/R4/metadataNo system-level interaction elementMetadata only; per-resource entries not covered here
Oracle Health open R4 sandboxfhir-open.cerner.com/r4/.../metadatabatch ("Implemented per the specification")Metadata only; no writes sent

The Epic sandbox CapabilityStatement and the Oracle Health open sandbox CapabilityStatement are both public. Neither settles what a specific Epic or Oracle Health customer's production server allows, so repeat the check on every production base URL. Servers without batch or transaction support SHOULD return HTTP 400, so try a one-entry batch before debugging a payload. More in our guide to reading an EHR's CapabilityStatement.

Writing into more than one EHR? Support for transaction, batch and conditional create differs by vendor and by customer site, and it decides how your write path has to be built. We build and run these integrations for product teams across Epic, Oracle Health, athenahealth, eClinicalWorks and NextGen. Talk to our team and we will map what each of your target servers actually accepts before you commit to a design.

Where teams get stuck with FHIR Bundles

Most Bundle bugs do not throw errors. They drop rows, double-count patients or leave half-written data, and they tend to surface only when real volumes hit a reconciliation report. These are the failure modes we see most when teams move from a sandbox to production traffic.

Stopping at page one

Sandbox patients often fit on one page, so a client that ignores next, or rebuilds URLs from _count and an offset, passes every test and then truncates in production.

Trusting Bundle.total and double-counting includes

Total can be missing or estimated, and it counts matches only. A pipeline that appends every entry duplicates Patients that repeat across pages. Split by search.mode and key includes on fullUrl.

Duplicate fullUrls in Bundles you build

Mapping code that mints a new urn:uuid: for every mention of the same patient creates two Patients, and reusing one UUID for two resources breaks bdl-7. Both can parse cleanly, which is why parsing vs clinical validation matters.

Assuming transactions work everywhere

HAPI rejected conditional references outright, and a server may declare batch without transaction. Plan a fallback early: resolve identifiers by search and send literal references, or sequence conditional creates with explicit cleanup. Finding this late means redesigning the write path.

Treating a batch HTTP 200 as success

The outer 200 only means the batch was processed. Parse every entry.response.status and retry or alert on anything outside 2xx.

Retrying a transaction that is not idempotent

Our first test transaction had ifNoneExist on the Patient and Encounter but not the Observation. The retry failed with HTTP 412 and HAPI-2840: Can not create resource duplicating existing resource: Observation/138554212. That came from this server's duplicate check; a server without one would have stored a second heart rate. Put ifNoneExist on every create you might retry.

POSTing to /Bundle instead of the base URL

The spec says a Bundle sent to the /Bundle endpoint is stored as-is and not processed as a batch, transaction or message. It looks like a successful write until someone searches for the data.

FHIR Bundle checklist before you ship

  1. Check rest.interaction on each target server for transaction and batch, then confirm with a read-only batch.
  2. Follow next links until none is returned; never build page URLs.
  3. Split entries by search.mode, deduplicate includes by fullUrl and log outcome entries.
  4. Treat Bundle.total as optional and as a count of matches only.
  5. Give each new resource a unique urn:uuid: fullUrl and reference it from the other entries.
  6. Put ifNoneExist on every create that might be retried, keyed on a business identifier.
  7. Use ifMatch or a conditional update for existing resources.
  8. Parse every entry.response.status in a batch-response and alert outside 2xx.
  9. Validate document Bundles (Composition first, identifier, timestamp) and message Bundles (MessageHeader first) before sending.
  10. POST transactions and batches to the base URL, not to /Bundle.

Bundles are where FHIR integrations quietly succeed or fail. Our healthcare interoperability team builds EHR integrations across Epic, Oracle Health, athenahealth, eClinicalWorks and NextGen, runs Mirth Connect interface engines in production, and maintains an open-source FHIR server that passes the ONC (g)(10) Inferno SMART App Launch test suite, 47 of 47. Our healthcare product engineering team builds the paging, retry and reconciliation layers around those connections. Talk to our team about the Bundle flows your product depends on.

Ready to scale?

Talk to our healthcare engineering team about building, integrating, and shipping faster.

Frequently Asked Questions

What is a FHIR Bundle used for?

A FHIR Bundle packages several resources into one payload. Servers return search results and version history as Bundles, clients send multi-resource writes as transaction or batch Bundles, and systems exchange clinical documents and messages as document or message Bundles. The required type element, one of nine codes in R4, decides which rules apply, including whether entries carry search, request or response details.

What is the difference between a FHIR transaction and a batch Bundle?

A transaction is atomic: every entry succeeds, or the server rejects the whole Bundle, returns HTTP 400 or 500 with an OperationOutcome and changes nothing. A batch processes each entry independently and returns HTTP 200 with a status per entry, so some entries can fail while others succeed. Only transactions may reference other new entries through urn:uuid values or use conditional references.

Does Bundle.total include _include resources?

No. In R4, Bundle.total is the number of resources that match the search across all pages. It excludes entries whose search.mode is include or outcome, and it is not the number of entries in the current page. It is also optional: a server may omit or estimate it, and the _total parameter is only a hint the server can ignore.

What does urn:uuid mean in a FHIR transaction Bundle?

A urn:uuid fullUrl is a temporary identity for a resource that does not exist on the server yet. Other entries reference that value, for example an Observation whose subject is the urn:uuid of a new Patient. When the server processes the transaction it assigns real ids and rewrites every matching reference, so the stored Observation points at the new Patient id.

How do I get the next page of FHIR search results?

Read the Bundle.link array and request the url whose relation is next, exactly as returned, and repeat until no next link appears. The R4 spec says paging links are opaque and clients must use the links the server supplies, so never build page URLs from _count and offsets. A page with fewer entries than _count does not prove you reached the end.

What is required in a FHIR document Bundle?

R4 requires a Composition as the first entry, a Bundle.identifier with both a system and a value, and a Bundle.timestamp. Resources the Composition references, such as its subject, author, encounter and section entries, must be included in the Bundle. The document is immutable once assembled, and a new document derived from the same Composition needs a new identifier.

How can I tell if an EHR FHIR server supports transaction Bundles?

Request [base]/metadata and look for transaction or batch in the CapabilityStatement rest.interaction list. Treat that as a starting point only: the public HAPI server processed a batch it did not declare, and a sandbox statement may differ from a customer's production server. Confirm with a harmless batch of GET requests against each production endpoint before you design the write path.
Share