Your FHIR Sandbox Looks the Part. Nobody in It Has Aged.
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: Every subscription in the account was already provisioned against a single source named Redox Dev Tools — the thing behind the Send button in their UI. There is no API credential for it, because it isn't that kind of source. On a subscription's detail page, Edit is disabled. The source can't be repointed. There's no New subscription button on the Connections page. The button that's there is Request new connection, which opens a form stating that "all connections are subject to a readiness and resource availability review before approval." My own API key authenticates as a different source — "Redox Request Ingress (Development)" — which nothing is subscribed to. Hence the 400. 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: _since boundary conditions. Inclusive or exclusive at the timestamp? Server clock vs. your clock? Resources written during your poll window — do you get them twice, or never? Replay idempotency. The same resource delivered twice. Do you upsert on a stable identifier, or do you now have two Patients? Out-of-order arrival. The Observation lands before the Encounter it references. Does your ingest dangle the reference, drop the resource, or 500? Version conflicts. A resource you cached is updated upstream. Does your If-Match on meta.versionId do the right thing, or do you silently clobber? Webhook retry dedup. The delivery times out on your end, the sender retries, you process it twice. Does anything downstream double-count? Pagination across a moving result set. You're on page 3 of 40 when new resources land. Do you skip records, repeat them, or loop? Cache invalidation. How would you even know? Nothing has ever invalidated. 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: Computes model time as anchor_model + (now - anchor_real) * speed, then finds every event-group whose anchor has come due. 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. 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: The bound is exclusive, lastUpdated > cursor, because R4 says "updated after." That means a resource written in the same millisecond as your cursor that you didn't receive will never arrive. Poll with a few minutes of overlap and dedupe on id + versionId. Our own $export uses an inclusive bound, which is an inconsistency I'd rather write down than quietly paper over. A malformed _since returns 400 instead of being ignored. Silently widening to "everything" is how this class of bug hides. A truncated delta says so in-band, as an OperationOutcome entry in the Bundle. A client that receives a short page and advances its high-water mark loses the remainder permanently — a worse failure than the one I started with, and an easy one to introduce while fixing it. 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: It's still synthetic. It won't replace a real integration test against a real EHR with real credentials. It gets you most of the way there before you have access — which is the whole point — but "most of the way" is the claim. The clock is compressed, not real-time. One model-week per real day is fast enough that deltas are always available and slow enough that a week of your testing spans months of patient history. It is not a live hospital feed, and if you need sub-second event latency this isn't it. FHIR Subscriptions aren't on the vendor twins, deliberately. You get them on /fhir. You don't get them on /v/epic or /v/cerner, because real Epic and Cerner production endpoints don't support Subscriptions, and a twin that's more capable than the thing it's imitating teaches you a lie. The twins stay faithful to what your integration will actually face. 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.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to