Research · 26 October 2026

Do FHIR servers store what they say they stored?

For valid data, yes. We sent 51 synthetic writes and 37 official ABDM and NHCX example bundles to four FHIR server setups and read every one back. The gaps sit in invalid input, which some setups accept and quietly trim, and in success replies that cover more than one outcome.

Run on 26 October 2026. HAPI FHIR 8.12.0 (twice: defaults, validation on), the HAPI public test server and the Firely Server public sandbox. Synthetic data only. Data integrity only, not clinical advice. How we did it · Download the data (CSV)

29 of 72

invalid FHIR writes got a success reply across the four setups; 8 of them were stored with part of what was sent removed.

Six findings.

  1. Valid data came back as sent: 14 of 14 fidelity and extension writes round-tripped on HAPI’s defaults, 14 on the HAPI public server and 14 on the Firely sandbox.

    Decimal precision (72.10), a +05:30 time offset, Devanagari and Tamil names, unknown and nested extensions, meta tags and narrative were all read back as sent. With HAPI’s request validation on, 13 of 14: it refused an extension whose URL it couldn’t resolve. The differences start when the input isn’t valid FHIR, or when “success” covers more than one outcome.

  2. 29 of 72 invalid writes got a success reply across the four setups, and 8 of those were stored with part of what was sent removed.

    Per setup, out of 18: HAPI 8.12.0 on its defaults 12 (4 changed), with request validation on 1, the HAPI public server 11 (4 changed), the Firely sandbox 5. R4 lets each server choose how strictly it validates, so this is configuration, and HAPI documents its lenient parser as the default.

  3. One malformed field, two different results: a status sent as ["final"] was stored with no status on HAPI’s defaults, and kept as an array on the Firely sandbox.

    Both answered 201 and 201. HAPI’s lenient parser left the required element out of the stored Observation; Firely returned the field as a JSON array on read, a shape R4 JSON doesn’t allow for a single value (its XML form showed a single value). With HAPI’s validation on, the write was refused (422). An agent reading only the status code would report the same thing in all three cases.

  4. All four setups answered 200 to a conditional create that stored nothing, and to an update that created no new version.

    This is R4 behaviour, not a defect: a conditional create with one match is ignored, and an update that changes nothing may keep the version. But the reply alone can’t tell a caller whether anything was written; only a read-back or the version id can. A stale If-Match was refused by all four, with 409, where R4 names 412.

  5. The version matters: HAPI 8.12.0 applied a conditional update whose body id didn’t match (200); the public server’s 8.13.10-SNAPSHOT build refused it (400), as R4 says.

    HAPI’s maintainers track this case (issue #8389). Firely refused it too (400). If you run an older HAPI, check what your version does before you rely on the id in the body.

  6. India: all 37 official ABDM and NHCX example bundles were stored and read back unchanged on three setups; with validation on and the Indian guide not loaded, none were accepted.

    301 of 301 entries came back identical on HAPI’s defaults, 301 on the HAPI public server and 301 on Firely, both as stored Bundles and unpacked as transactions. With HAPI’s request validation on, 0 of 74 writes were accepted: 44 replies named an NRCeS profile the server didn’t have. Validation that hasn’t loaded ndhm.in refuses Indian records it can’t check.

What each setup did with 51 writes.

Each bar is one setup. Stored as sent, a success reply after which the read-back differed from what was sent, invalid input accepted and stored as sent, and refused.

Outcome of the 51 core probes on each server setup
  • HAPI 8.12.0 (defaults)HAPI FHIR JPA Server 8.12.0 · run locally 22 as sent · 8 differed · 9 invalid kept · 12 refused
  • HAPI 8.12.0 (validation on)HAPI FHIR JPA Server 8.12.0 · run locally 20 as sent · 4 differed · 1 invalid kept · 26 refused
  • HAPI public test serverHAPI FHIR Server 8.13.10-SNAPSHOT · public server 22 as sent · 8 differed · 8 invalid kept · 13 refused
  • Firely public sandboxFirely Server 6.11.0 · public server 25 as sent · 5 differed · 5 invalid kept · 16 refused

“Read-back differed” includes R4 behaviour such as a conditional create that stores nothing, the server replacing a client id on create, and HAPI removing versions from references by default. It is a difference between the reply and the stored state, not a defect.

Invalid writes that got a success reply.

Out of 18 writes that aren’t valid FHIR R4, or that R4 says a server refuses. Validation strictness is a server setting.

  • HAPI 8.12.0 (defaults) 12 of 18 4 stored with parts removed
  • HAPI 8.12.0 (validation on) 1 of 18 0 stored with parts removed
  • HAPI public test server 11 of 18 4 stored with parts removed
  • Firely public sandbox 5 of 18 0 stored with parts removed

Every probe, every setup.

“As sent”: a success reply and an identical read-back. “Changed”: a success reply, and the read-back differs. “Accepted”: invalid input answered with success. “Nothing new”: a success reply with no new resource or version. The CSV has the HTTP status, the paths that were dropped or changed, and the server’s message for each cell.

Outcome of each probe on each server setup
ProbeHAPI 8.12.0 (defaults)HAPI 8.12.0 (validation on)HAPI public test serverFirely public sandbox
Field fidelity
Valid Patient with many elements As sent 201As sent 201As sent 201As sent 201
Names in Devanagari and Tamil script As sent 201As sent 201As sent 201As sent 201
Decimal 72.10 (trailing zero is significant in FHIR) As sent 201As sent 201As sent 201As sent 201
dateTime with +05:30 offset As sent 201As sent 201As sent 201As sent 201
meta.tag, meta.security and meta.profile sent by client As sent 201As sent 201As sent 201As sent 201
Client-supplied narrative (text.div) As sent 201As sent 201As sent 201As sent 201
Narrative text kept on read-back As sent 201As sent 201As sent 201As sent 201
Contained Practitioner referenced from performer As sent 201As sent 201As sent 201As sent 201
POST with an id in the body (R4: server ignores it) Changed 201Changed 201Changed 201Changed 201
Extensions
Core extension (patient-birthPlace) As sent 201As sent 201As sent 201As sent 201
Extension with an unknown, unresolvable URL As sent 201As sent 201As sent 201As sent 201
Extension on a primitive (_birthDate) As sent 201As sent 201As sent 201As sent 201
Complex extension with nested extensions As sent 201As sent 201As sent 201As sent 201
Unknown modifierExtension (changes meaning; R4 lets a server refuse) As sent 201As sent 201As sent 201As sent 201
Extension with neither value nor children (invariant ext-1) · invalid input Accepted 201Refused 422Accepted 201Refused 400
Extension with a relative URL (extension.url must be absolute) · invalid input Accepted 201Refused 422Accepted 201Accepted 201
NRCeS-style profile extension URL on Patient As sent 201Refused 422As sent 201As sent 201
References
Observation.subject points to a Patient that does not exist Refused 400Refused 400Refused 400As sent 201
Observation.subject points to a Medication (not an allowed target type) · invalid input Refused 422Refused 422Refused 422Accepted 201
Absolute reference to another server Refused 400Refused 400As sent 201As sent 201
Logical reference (identifier only, no literal reference) As sent 201As sent 201As sent 201As sent 201
Contained resource nobody references (invariant dom-3) · invalid input Accepted 201Refused 422Accepted 201Refused 400
Versioned reference Patient/x/_history/1 Changed 201Changed 201Changed 201As sent 201
Conditional create and update
Conditional create, one match (R4: ignore, answer 200) Nothing new 200Nothing new 200Nothing new 200Nothing new 200
Conditional create, two matches (R4: 412) Refused 412Refused 412Refused 412Refused 412
Conditional update, no match (R4: server creates) As sent 201As sent 201As sent 201As sent 201
Conditional update, two matches (R4: 412) Refused 412Refused 412Refused 412Refused 412
Update that changes nothing: does a new version appear? Nothing new 200Nothing new 200Nothing new 200Nothing new 200
Update with a stale If-Match (R4: 412) Refused 409Refused 409Refused 409Refused 409
Update with no If-Match over a version the client never read As sent 200As sent 200As sent 200As sent 200
PUT to a new id (update-as-create) As sent 201As sent 201As sent 201As sent 201
Same create sent twice (a retry after a lost answer) As sent 201As sent 201Refused 412As sent 201
Conditional update whose body id differs from the one match (R4: 400) · invalid input Accepted 200Accepted 200Refused 400Refused 400
Transactions and batches
Transaction: 3 Observations, the 3rd has no status (R4: all or nothing) · invalid input Accepted 200Refused 422Accepted 200Refused 400
Batch: same 3 entries (R4: each entry on its own) · invalid input · batch control Accepted 200Refused 422Accepted 200Partial 200
Transaction where one entry references a missing Patient Refused 400Refused 400Refused 400As sent 200
Transaction: urn:uuid reference rewritten to the new Patient id As sent 200As sent 200As sent 200As sent 200
Transaction: 3 creates plus one update with a stale If-Match · invalid input Refused 409Refused 409Refused 409Refused 409
Transaction with two conditional creates on the same identifier As sent 200As sent 200As sent 200Changed 200
Validation strictness
Observation with no status (1..1) · invalid input Accepted 201Refused 422Accepted 201Refused 400
Observation.status = "done" (not in required value set) · invalid input Refused 400Refused 422Refused 400Refused 400
birthDate "21/10/1980" · invalid input Refused 400Refused 422Refused 400Refused 400
birthDate "1980-02-30" · invalid input Refused 400Refused 422Refused 400Accepted 201
valueQuantity.value sent as the string "72" · invalid input Accepted 201Refused 422Accepted 201Accepted 201
Unknown element "favouriteColour" on Patient · invalid input Accepted 201Refused 422Accepted 201Refused 400
Empty string in name.family (R4: strings must not be empty) · invalid input Accepted 201Refused 422Accepted 201Refused 400
gender "M" (required binding: male|female|other|unknown) · invalid input Refused 400Refused 422Refused 400Refused 400
Observation with both a value and dataAbsentReason (invariant obs-6) · invalid input Accepted 201Refused 422Accepted 201Refused 400
status sent as an array ["final"] · invalid input Accepted 201Refused 422Accepted 201Accepted 201
meta.profile naming a profile the server does not have As sent 201Refused 422As sent 201As sent 201
Observation with no code (1..1) · invalid input Accepted 201Refused 422Accepted 201Refused 400

India: ABDM health records and NHCX claim bundles.

The 37 example bundles published with the NRCeS FHIR Implementation Guide for ABDM 6.5.0: discharge summaries, prescriptions, lab and imaging reports, immunization records, invoices, and NHCX claim, pre-authorisation, coverage-eligibility and task bundles. Names, phone numbers, addresses and identifiers were replaced with synthetic values first.

ABDM and NHCX example bundles on each setup
SetupStored as a Bundle, read back unchangedUnpacked as a transaction, acceptedEntries read back unchanged
HAPI 8.12.0 (defaults)37 of 3737 of 37301 of 301
HAPI 8.12.0 (validation on)0 of 370 of 370 of 301
HAPI public test server37 of 3737 of 37301 of 301
Firely public sandbox37 of 3737 of 37301 of 301

With HAPI’s request validation on, the server validated against the base R4 profiles only; the ndhm.in package wasn’t loaded, and 44 of its 74 refusals name an NRCeS profile it didn’t have. We didn’t run a setup with the package loaded (see the limits). HAPI returned narrative with its attributes in a different order, and left out an entry’s resource id where it repeated the entry’s urn:uuid; we count both as unchanged.

How we did it.

  1. Servers. HAPI FHIR JPA Server 8.12.0, from the official hapiproject/hapi:v8.12.0-2 image’s application archive, run on Java 21 with its in-memory database and the starter’s defaults, then again with hapi.fhir.validation.requests_enabled=true. The HAPI public test server at hapi.fhir.org/baseR4 (build 8.13.10-SNAPSHOT/6bd250944e/2026-09-25) and the Firely Server public sandbox at server.fire.ly/R4 (6.11.0), each as its operator configured it. All FHIR 4.0.1.
  2. Probes. 51 writes in six groups: field fidelity, extensions, references, conditional create and update, transactions and batches, and validation strictness. Each carries an identifier unique to the run. Then the 37 NRCeS examples, each stored as a Bundle and unpacked into a transaction.
  3. Read back. After each write the probe reads the resource back and compares it with what it sent, element by element. It ignores id, meta.versionId, meta.lastUpdated and meta.source, and treats three things as the same value: narrative with attributes in another order, a relative reference returned as an absolute URL on the server’s own base, and a number sent as a numeric string and stored as a number.
  4. Public servers. The HAPI test server says it is “not a production server” and is reset regularly; Firely’s documentation calls its sandbox “intended for testing and educational purposes”. We wrote only synthetic data, paused 400 ms before each request, and deleted every resource we created. Searches for the study’s identifiers on both servers returned none afterwards.

Its limits.

  • Two implementations, four setups. Medplum, Blaze, Microsoft’s FHIR server and the managed cloud services weren’t in this run: Blaze needs a native library our machine lacked without Docker, and Medplum is covered by our agent write benchmark.
  • Every result depends on configuration. The public servers run settings we can’t see; the HAPI public server, for one, refuses a create whose content repeats an existing resource (HAPI-2840), which changed our retry probe there.
  • We didn’t run HAPI with the ndhm.in package loaded: the machine’s disk limit stopped that setup mid-start. Whether the examples pass a server that has the guide loaded is open.
  • A read-back sees what the server returns, not its tables. A server could store more than it returns, or return what it cached.
  • Deletes on both public servers keep earlier versions in history, as R4 allows. The data was synthetic, and HAPI resets its test server regularly.
  • The HAPI public run used the final probe; the first pass there was set aside because the server’s duplicate check blocked two seeds. Both runs are in the raw files. The run marker we added to each ABDM entry isn’t valid on Binary resources, so its removal there isn’t counted.
  • We make Deedglass, which checks agent writes by reading them back, so we have an interest in this question. The probe, the raw results and the aggregate are published so anyone can rerun it.

Responsible disclosure.

Before publishing, we checked each behaviour named here against the project’s documentation and its issue tracker, and re-ran the two Firely results by hand. The conditional-update id case is already tracked by HAPI (#8389). For the rest, we have drafted notes to the maintainers and haven’t filed them; nothing here is a security issue. If you maintain one of these servers and read a result differently, write to us and we will correct the page.

Cite as: Deedglass, “FHIR write integrity 2026: do FHIR servers store what they say they stored?”, deedglass.com/research/fhir-write-integrity-2026, run 2026-10-26. The scripts: probe, aggregate.

Questions.

Do FHIR servers store what they say they stored?

For valid data, in our 26 October 2026 run, yes: the fidelity and extension writes came back as sent on every setup that accepted them. The gaps were in invalid input (29 of 72 invalid writes got a success reply across four setups, 8 stored with parts removed) and in replies like a 200 for a conditional create that stores nothing.

Which FHIR servers and versions were tested?

HAPI FHIR JPA Server 8.12.0 run locally twice (starter defaults, then with request validation on), the HAPI public test server (build 8.13.10-SNAPSHOT/6bd250944e/2026-09-25) and the Firely Server public sandbox (6.11.0). Each setup is named with its configuration; results depend on configuration.

Can ABDM and NHCX bundles be stored on an open-source FHIR server?

In this run, all 37 example bundles from the NRCeS FHIR Implementation Guide for ABDM 6.5.0 were stored and read back unchanged on HAPI’s defaults, the HAPI public server and the Firely sandbox. With HAPI’s request validation on and the guide’s package not loaded, none were accepted.

Is this medical advice or a clinical safety test?

No. It measures data integrity only: whether what a server stored matches what was sent. Every resource was synthetic, and nothing here judges clinical content.