Debugging a FHIR Transaction Bundle That Won't Post
Hl7 Tools

Debugging a FHIR Transaction Bundle That Won't Post

The Bundle That Comes Back 400

You assemble a transaction bundle โ€” a Patient, an Encounter, three Observations โ€” POST it to the server root, and get a single unhelpful rejection. Because a transaction is atomic, one bad entry takes the whole thing down, and the error rarely names the entry that caused it. Debugging is therefore mostly a matter of inspecting the bundle you actually sent, in a form where the relationships are visible.

This guide walks the four things that break real transaction bundles: fullUrl addressing with urn:uuid, the semantics of entry.request, references that fail to resolve, and the ordering and dependency questions everyone worries about more than they should. Everything here is based on the FHIR R4 specification's RESTful API and Bundle pages. To inspect a bundle's resources and reference graph locally while you work, use the FHIR Bundle Explorer; for the anatomy of Bundle itself, start with What Is a FHIR Bundle.

fullUrl and the urn:uuid Trick

The problem a transaction bundle has to solve is circular. You want to create a Patient and an Observation in one atomic call, and the Observation must point at the Patient โ€” but the Patient has no server-assigned id yet, because it does not exist until the server creates it. You cannot write Patient/123 because you do not know the 123.

FHIR's answer is Bundle.entry.fullUrl combined with the urn:uuid: scheme. You mint a UUID client-side, put urn:uuid:8f2c1e4a-โ€ฆ in the Patient entry's fullUrl, and have the Observation reference that same urn:uuid: string in subject.reference. The server, when it creates the Patient and assigns a real id, rewrites every matching link in the bundle to the new location. The R4 specification is explicit about the breadth of that rewrite: the server "SHALL replace all matching links in the bundle, whether they are found in the resource ids, resource references, elements of type uri, url, oid, uuid," and even href and src attributes inside the narrative.

Three consequences follow, and each is a common bug.

  • The temporary identifier must match exactly. The reference string and the fullUrl are compared as strings. A UUID that differs in case, a stray whitespace, a urn:uuid: prefix present on one side and missing on the other โ€” any of these breaks the link, and the server sees a reference to something that does not exist.
  • On a POST, the resource's own id is ignored. The spec states that when processing a create, the full URL is treated as the id of the resource on the source and is ignored; the server generates an id. Putting a hopeful "id": "patient-1" in a POST entry and referencing Patient/patient-1 elsewhere will not work โ€” that id is not what the server will use.
  • fullUrl must be unique within the bundle. The Bundle invariant on this point (bdl-5) requires fullUrl to be unique in a bundle, or else entries sharing a fullUrl must differ by meta.versionId, outside of history bundles. Copy-pasting an entry and forgetting to change its UUID gives you two entries claiming the same address, and reference resolution becomes ambiguous.

A related rule that catches people building bundles programmatically: a resource can only appear in a transaction once, by identity. Two entries that would resolve to the same resource are not "the last one wins" โ€” they are an error.

When to Use urn:uuid and When Not To

Use urn:uuid for resources the bundle is creating. For resources that already exist on the target server, use the real absolute or relative URL โ€” Patient/123 or http://server/fhir/Patient/123 โ€” because there is nothing to rewrite and inventing a UUID for an existing resource just creates a link with no target. Mixing the two in one bundle is normal and correct: new Observations pointing at an existing Patient by Patient/123, and at a brand-new Encounter by urn:uuid.

entry.request: The Part That Is Not the Resource

Every entry in a transaction or batch carries a request element that says what to do with the resource. Per Bundle invariant bdl-3, entry.request is mandatory for batch, transaction and history bundles and prohibited otherwise โ€” which is why pasting a searchset or a collection bundle into a transaction POST fails immediately, and why a document bundle cannot be posted as a transaction without being rebuilt.

Two fields do the work. request.method is one of POST, PUT, PATCH, DELETE, GET or HEAD. request.url is the relative URL the operation targets. The pairing is where mistakes concentrate:

  • POST โ€” url is the resource type alone: Patient. Not Patient/123. This is a create; the server assigns the id.
  • PUT โ€” url is type plus id: Patient/123. This is an update-or-create at a known address. If you know the id you want, PUT is how you keep it; POST will not honour it.
  • DELETE โ€” url is type plus id, and the entry carries no resource.
  • GET / HEAD โ€” a read inside the transaction, again with no resource body.

A POST or PUT entry with no resource, or a DELETE entry that carries one, is malformed. So is a request.url written as an absolute URL against a different server, or one that starts with a leading slash โ€” request.url is relative to the server base.

Conditional Operations Through the request Element

request also carries the conditional headers, and they are the usual reason a "duplicate patient" bug appears in production. ifNoneExist turns a POST into a conditional create: the server runs the supplied search, and if exactly one match is found it ignores the post and returns 200 OK rather than creating a second copy; multiple matches produce a 412. ifMatch, ifNoneMatch and ifModifiedSince similarly guard updates and reads. If your integration creates a new Patient every run because the demographics arrive again, ifNoneExist on a stable business identifier is usually the fix.

Debugging a FHIR Transaction Bundle That Won't Post

Why a Reference Fails to Resolve

"Reference does not resolve" is the most common transaction failure and it has a small number of causes. Working through them in order finds nearly all of them.

  • No entry has that fullUrl. The reference points at a urn:uuid that no entry claims โ€” usually a UUID generated twice, or an entry dropped during assembly.
  • Wrong addressing form. The entry is addressed by urn:uuid but referenced as Patient/8f2cโ€ฆ, or the reverse. Within a bundle these are different strings and only one of them matches.
  • The target is genuinely external. The reference points at a resource on the server that the bundle does not contain. This is legitimate, and whether it resolves depends entirely on that resource existing on the target server โ€” something no local inspection can confirm.
  • A logical reference was used where a literal one was expected. FHIR allows Reference.identifier instead of Reference.reference when no literal URL is available. It is valid, but it is not a link the bundle can resolve, and servers differ in whether they accept, resolve or reject one. How FHIR References Work covers the distinction in depth.
  • Contained-resource confusion. A #p1 fragment reference points at a resource contained inside the same resource, not at a bundle entry. Moving a contained resource out to its own entry without updating the reference leaves a fragment pointing at nothing.

This is where local inspection pays for itself. The FHIR Bundle Explorer builds a reference graph across the resources in your file, matching each reference by Type/id, bare id, fullUrl, and the trailing Type/id of a fullUrl โ€” the four forms entries in one bundle realistically use to address each other. Anything that matches none of them is listed as unresolved rather than hidden, which turns "the server said no" into a specific list of reference strings with nothing to point at. Resolution is strictly local: nothing is ever fetched over the network, because a bundle is patient data and fetching a referenced resource from an external server would leak it.

Ordering and Dependencies: Less Important Than You Think

The most persistent myth about transaction bundles is that entries must be ordered so dependencies come first โ€” Patient before Encounter, Encounter before Observation. They do not. The R4 specification defines the processing sequence by method, not by document order: the server processes any DELETE interactions, then any POST interactions, then any PUT or PATCH interactions, then any GET or HEAD interactions, and finally resolves conditional references.

Because all the creates happen in one phase and the link rewriting is applied across the whole bundle, an Observation entry sitting before its Patient entry works fine. Where ordering does matter is between phases: a GET later in the sequence sees the results of the earlier creates, and a DELETE runs before everything, which can surprise you if you delete and recreate the same resource in one bundle.

Real dependency problems in transaction bundles are almost never about sequence. They are about identity โ€” the reference and the fullUrl not being the same string โ€” or about conditional references that cannot be resolved because the search they encode matches zero or many resources. If you find yourself reordering entries to fix a failure, the failure is probably somewhere else.

Transaction or Batch?

One more decision worth revisiting when debugging. A transaction is atomic: servers either accept all actions and return 200 OK, or reject everything with a 400- or 500-class response. A batch processes entries independently and reports an outcome per entry. If your bundle's entries are genuinely independent โ€” a night's worth of unrelated Observations, say โ€” a batch tells you exactly which three of two hundred failed, instead of failing all two hundred. If the entries are one clinical event that must land together, a transaction is the right choice and the atomicity is the point. Debugging a batch is considerably easier, so switching to batch temporarily is a legitimate diagnostic move even when transaction is the production answer.

A Debugging Order That Works

When a transaction bundle is rejected, work from structure outward:

  • Confirm Bundle.type is transaction and every entry has a request with a method and a relative URL.
  • Check method/url pairings: POST to a bare type, PUT to type/id, no resource body on DELETE.
  • List every fullUrl and confirm uniqueness; list every reference string and match it against that set. Anything unmatched is either external on purpose or a bug.
  • Check that POST entries are not relying on their own id, and that referenced ids are not ones you hoped the server would honour.
  • Only then look at conformance โ€” required elements, bindings, profiles โ€” with a validator such as our FHIR Resource Validator. A structurally sound bundle can still fail on cardinality; see FHIR validator cardinality and slicing errors.

Keep the two concerns separate as you go: a bundle explorer answers "what is in here and how does it link together", a validator answers "is each resource conformant". The Bundle Explorer is deliberately read-only and does not validate, which is exactly why it is safe to point at a production bundle mid-incident. It handles inputs up to 25 MB and 20,000 resources, reporting the dropped count explicitly if a cap is reached, and does everything in your browser โ€” no PHI is transmitted while you debug. If your source is a bulk export rather than a hand-built transaction, opening an NDJSON FHIR export covers the other shape the same tooling reads.

← Back to Blog