Your FHIR Sandbox Looks the Part. Nobody in It Has Aged.

Sandbox patients never change, so an entire class of integration bug can't surface — including one sitting in HAPI, the reference server, for years.

mock.health · 11 min read · 2026-08-08


Epic's sandbox, an integration platform's dev environment, and the JSON fixtures in your repo.


Pick any patient out of Open Epic's sandbox. Camila Lopez, say. Query her today:

curl -H "Authorization: Bearer $TOKEN" \
  "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4/Patient/erXuFYUfucBZaryVksYEcMg3"

Now query her again next month. Next year. It's the same bytes. Same conditions, same encounters, same meta.lastUpdated. That timestamp has not moved in years, and it is never going to.

This is not a complaint about data quality. I've written that post already — the sandbox patients are thin, their names are "Test Cancer," and you can't demo off them. That's the content problem, and it's well understood.

This is a different problem, and I think it's the more expensive one, because it's invisible. The sandbox doesn't just contain bad data. The sandbox doesn't contain time.

The test you cannot write

Here is the single most important request your integration will ever make:

curl -H "Authorization: Bearer $TOKEN" \
  "$BASE/Patient/$ID/\$everything?_since=2026-08-01T00:00:00Z"

"What changed since I last looked." Every production integration is built on some version of this — a delta poll, a subscription, a nightly bulk-export diff. It is how you avoid re-ingesting a million resources every hour. It is the hot path.

Run it against a static sandbox and you get an empty Bundle. Run it with _since set to the epoch and you get everything, once, and then empty Bundles forever after.

So your delta-sync test passes.

It passes because there is nothing to sync. You have written a test that asserts your code correctly handles zero changes, and you have learned nothing about whether it handles one. The first time your code sees a real delta is in a customer's production environment, against real PHI, on a Tuesday.

Three flavors of nothing happening

Static sandboxes aren't all static the same way, and the differences matter when you're trying to work around them.

1. Epic's sandbox: frozen

Open Epic gives you eight named test patients — Camila Lopez, Derrick Lin, Desiree Powell, Elijah Davis, Linda Ross, Olivia Roberts, Warren McGinnis, Jason Argonaut. They are a fixture set. They exist so you can confirm your OAuth flow completes, your client parses a Bundle, and your pagination doesn't crash.

For that job they are honestly fine. Epic never claimed otherwise. But nothing in that environment ever transitions: no encounter opens, no result posts, no condition resolves, no resource gets a new versionId. It's a photograph of a clinic, and you're trying to test a video player.

2. An integration platform's dev environment: provisioned like production

The second flavor is more interesting, because the platform is perfectly capable of automation and you still can't get at it.

We ship an inbound Redox destination — you register https://api.mock.health/api/redox and Redox delivers to it. I built it, documented it, then went to test it the way a customer would: send data through the API, watch it arrive.

It didn't arrive. Redox answered with a 400 that named the missing piece:

"Errors": [{
  "Module": "Subscriptions",
  "Text": "No subscriptions. Meta.Destinations needs to contain a
           destination from existing subscriptions."
}]

Fair enough — a subscription links a source to a destination, and once one exists, messages route automatically with nobody watching. That's real automation and it's documented. My mistake, and I went to go create one.

I couldn't. In the Development environment:

So the automation exists, and the only source wired to my destination is the one with a human in front of it. Getting a different one wired means filing a request and waiting for a person at Redox to approve it.

That's a defensible design. Redox moves real PHI between real health systems; gating who can route data where is the correct instinct, and I'd be more worried if a stranger could self-serve a pipe into a hospital. But it means their dev environment is provisioned like production — and an environment provisioned like production isn't a sandbox. It's a staging area you have to be onboarded into.

For a connectivity check, that's fine. For "run my integration suite nightly against my pipeline," a human approval queue upstream of your first test is the whole ballgame.

One caveat, honestly. Redox's docs describe a New subscription button I don't have, so what I hit may be a permission or account-tier artifact rather than a universal property of dev environments. I've asked them. If it's self-serve for you and I simply couldn't see it, tell me and I'll correct this post with a link.

3. Your own fixtures: stale by construction

The one nobody wants to talk about, because it's self-inflicted.

You gave up on vendor sandboxes and seeded your own Postgres with a JSON fixture set. It was great. It was your data, shaped how you needed it.

Then the dates hardcoded themselves into your test suite. That "recent" encounter you seeded in January reads as eight months stale in August. Your "active" medication has an end date in the past. Your HbA1c trend stops. Somebody adds dateutil.relativedelta(months=-3) to keep one patient current, and now your fixtures aren't deterministic, and now a test fails on the first of the month for reasons nobody can reproduce.

Static fixtures don't stay still. They rot in place, which is worse, because a frozen sandbox is at least honestly frozen.

The bugs this hides

This is the actual argument. A corpus that never changes cannot produce a failing test for any of these:

Every one of these is a time bug. Every one of them ships to production untested if your sandbox is a photograph. And they don't fail loudly — they fail as a duplicate patient in a customer's chart, or a lab result that quietly never arrived.

What we built instead

mock.health's sandbox runs a clock.

A timeline walker advances each patient's pre-generated history into the live sandbox on a compressed schedule — roughly one model-week per real day. A scheduler hits an internal endpoint hourly. Each tick:

  1. Computes model time as anchor_model + (now - anchor_real) * speed, then finds every event-group whose anchor has come due.
  2. Re-dates the group so its clinical timestamps land at the real moment it "happens" on the compressed clock. Intra-group offsets are preserved, so an admission still precedes its discharge by the right interval. birthDate is never shifted — patients don't get younger.
  3. Writes it as a transaction bundle, PUT with client-assigned UUIDs.

That third step is the one that makes the whole thing work, and it's almost boring: a real write produces a real meta.lastUpdated. Which means _lastUpdated polling and bulk-export deltas return genuinely fresh results — on /fhir and on every /v/* vendor twin. We didn't build a delta API. We made data actually change, and FHIR's existing delta semantics started working on their own.

Each walked event-group also appends a CDC row to a cursor-based change feed (GET /api/feed), gets pre-rendered into HL7v2 (ADT^A01/A04/A03, ORM^O01, ORU^R01) and C-CDA — the C-CDA written back as a DocumentReference you can find via $docref — and fires HMAC-signed webhooks to registered subscribers.

And when a dataset's timeline is exhausted, it re-arms: cursors reset, the anchor moves to now, and history replays. Because the writes are PUTs with stable UUIDs, a replay is an idempotent update with fresh timestamps rather than a pile of duplicates. The sandbox doesn't run out.

The part where I found the same bug in my own server

I wrote that line about FHIR's delta semantics working on their own, then went to verify it the way I'd want a reader to. Pick a patient, poll $everything with a future _since, confirm you get an empty Bundle.

I got the entire compartment. 122 resources, none of them newer than a date in 2099.

HAPI accepts _since on Patient/$everything and ignores it. Not rejects: ignores.

I want to sit on that for a second, because HAPI is not some abandoned side project. It's the open-source reference implementation — the thing a large share of FHIR stacks are built on, ours included, and the thing people reach for when they want to know how a behavior is supposed to work. It is very good software. And this is in it.

My first assumption was that I'd misconfigured something. I ran the same test against the public hapi.fhir.org server: same answer. It reproduces on 8.8 through 8.11.

The obvious defense is that _since is optional. And it is — sort of. In the R4 OperationDefinition the parameter is 0..1, which means a client doesn't have to send one. It does not mean a server that receives one may pretend it didn't. Three things make that reading hard to sustain here:

HAPI declares the canonical operation, not a reduced one. Ask its CapabilityStatement what $everything is and it answers with HL7's own definition:

everything  ->  http://hl7.org/fhir/OperationDefinition/Patient-everything

Every other operation it implements points at a HAPI-local OperationDefinition describing what HAPI actually does — .../OperationDefinition/Multi-its-meta, .../Global-is-diff. For $everything it points at the spec's definition, the one that contains _since and says the parameter exists so "a client can request only records that have changed since the last request." So the server isn't declining an optional feature. It's naming the full contract and then implementing part of it.

It filters everything else on that same operation. _type works. _lastUpdated works on ordinary type-level searches. The date filter just never got wired into $everything.

And the response says the filter ran. FHIR's mechanism for telling a client which parameters a server actually honored is the Bundle's self link. Here's what comes back for a cursor in 2099:

self: .../Patient/{id}/$everything?_count=2&_since=2099-01-01T00%3A00%3A00Z

The parameter is echoed. No OperationOutcome, no warning, no code: not-supported. The one channel a well-behaved client would check to detect a dropped parameter reports success while handing back the entire compartment. That's what turns a missing feature into a silent one.

I filed it upstream — hapifhir/hapi-fhir#8039. This is no complaint about the maintainers; the HAPI team ships an enormous amount of FHIR for free. It's evidence for the actual point, which is that this survived in the reference implementation, in front of everybody, for years — because of the exact thing this post is about:

On a static sandbox, "correctly returned nothing" and "ignored your filter" are the same response. Camila Lopez has no resources newer than your cursor either way. The bug is only observable against data that moves. Our clock is what surfaced it.

The fix is unglamorous. When a request carries _since, the API stops forwarding the operation and assembles the answer itself from per-type compartment searches with _lastUpdated=gt, which HAPI does get right. Same URL, same Bundle shape, and now the filter is real:

curl -H "Authorization: Bearer $KEY" \
  "https://api.mock.health/fhir/Patient/$ID/\$everything?_since=2099-01-01T00:00:00Z"
# {"resourceType":"Bundle","type":"searchset","total":0,"entry":[]}

Three things fell out of doing it, and they're all the boundary conditions from the list above:

I'm keeping this section rather than editing the post into a cleaner story, because the cleaner story would be false and the messy one is the argument. I did not find this by reading the spec. I found it because the data moved.

So the test you couldn't write, you can write:

# Snapshot
curl -H "Authorization: Bearer $KEY" \
  "https://api.mock.health/fhir/Patient/$ID/\$everything?_since=$(date -u -d '1 hour ago' +%FT%TZ)"

# Wait. Ask again. Different answer.

What this doesn't give you

Three honest limits, because a living sandbox is not a production environment:

The Free tier gets _since polling and the FHIR change feed against the sample dataset — enough to prove the delta path in your client works. Renditions (HL7v2, C-CDA), webhooks, and Subscriptions are Pro.

The point

Sandboxes are graded on the wrong axis. The industry conversation is about whether the data is realistic — enough conditions, plausible names, correct codes. That matters, and it's the easier problem.

The harder one is that a sandbox with perfect data and no clock still can't test the half of your integration that deals with change. And that half is where the production incidents come from, because it's the half you shipped untested.

Ask your sandbox what changed in the last hour. If the answer is always "nothing," you don't have a test environment. You have a fixture.


Related posts

All posts · Home · Docs