<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: mock health</title>
    <description>The latest articles on DEV Community by mock health (@mockhealth).</description>
    <link>https://dev.to/mockhealth</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3838912%2F91ebfbe2-b5be-4786-8f4c-cf10f617d99c.png</url>
      <title>DEV Community: mock health</title>
      <link>https://dev.to/mockhealth</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mockhealth"/>
    <language>en</language>
    <item>
      <title>Your FHIR Sandbox Looks the Part. Nobody in It Has Aged.</title>
      <dc:creator>mock health</dc:creator>
      <pubDate>Mon, 10 Aug 2026 22:58:19 +0000</pubDate>
      <link>https://dev.to/mockhealth/your-fhir-sandbox-looks-the-part-nobody-in-it-has-aged-3g02</link>
      <guid>https://dev.to/mockhealth/your-fhir-sandbox-looks-the-part-nobody-in-it-has-aged-3g02</guid>
      <description>&lt;p&gt;&lt;em&gt;Epic's sandbox, an integration platform's dev environment, and the JSON fixtures in your repo.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Pick any patient out of Open Epic's sandbox. Camila Lopez, say. Query her today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4/Patient/erXuFYUfucBZaryVksYEcMg3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;This is not a complaint about data quality. I've &lt;a href="https://dev.to/blog/fhir-sandbox-problem"&gt;written that post already&lt;/a&gt; — the sandbox patients are thin, their names are "Test Cancer," and you can't demo off them. That's the &lt;em&gt;content&lt;/em&gt; problem, and it's well understood.&lt;/p&gt;

&lt;p&gt;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. &lt;strong&gt;The sandbox doesn't contain time.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The test you cannot write
&lt;/h2&gt;

&lt;p&gt;Here is the single most important request your integration will ever make:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/Patient/&lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;everything?_since=2026-08-01T00:00:00Z"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"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.&lt;/p&gt;

&lt;p&gt;Run it against a static sandbox and you get an empty Bundle. Run it with &lt;code&gt;_since&lt;/code&gt; set to the epoch and you get everything, once, and then empty Bundles forever after.&lt;/p&gt;

&lt;p&gt;So your delta-sync test passes.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three flavors of nothing happening
&lt;/h2&gt;

&lt;p&gt;Static sandboxes aren't all static the same way, and the differences matter when you're trying to work around them.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Epic's sandbox: frozen
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;versionId&lt;/code&gt;. It's a photograph of a clinic, and you're trying to test a video player.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. An integration platform's dev environment: provisioned like production
&lt;/h3&gt;

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

&lt;p&gt;We ship an inbound Redox destination — you register &lt;code&gt;https://api.mock.health/api/redox&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;It didn't arrive. Redox answered with a 400 that named the missing piece:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Subscriptions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No subscriptions. Meta.Destinations needs to contain a
           destination from existing subscriptions."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fair enough — a &lt;strong&gt;subscription&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;I couldn't. In the Development environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every subscription in the account was already provisioned against a single source named &lt;strong&gt;Redox Dev Tools&lt;/strong&gt; — the thing behind the &lt;em&gt;Send&lt;/em&gt; button in their UI. There is no API credential for it, because it isn't that kind of source.&lt;/li&gt;
&lt;li&gt;On a subscription's detail page, &lt;strong&gt;Edit is disabled&lt;/strong&gt;. The source can't be repointed.&lt;/li&gt;
&lt;li&gt;There's no &lt;strong&gt;New subscription&lt;/strong&gt; button on the Connections page. The button that's there is &lt;strong&gt;Request new connection&lt;/strong&gt;, which opens a form stating that &lt;em&gt;"all connections are subject to a readiness and resource availability review before approval."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;My own API key authenticates as a &lt;em&gt;different&lt;/em&gt; source — "Redox Request Ingress (Development)" — which nothing is subscribed to. Hence the 400.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's a defensible design.&lt;/strong&gt; 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 &lt;em&gt;provisioned like production&lt;/em&gt; — and an environment provisioned like production isn't a sandbox. It's a staging area you have to be onboarded into.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One caveat, honestly.&lt;/strong&gt; Redox's docs describe a &lt;em&gt;New subscription&lt;/em&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Your own fixtures: stale by construction
&lt;/h3&gt;

&lt;p&gt;The one nobody wants to talk about, because it's self-inflicted.&lt;/p&gt;

&lt;p&gt;You gave up on vendor sandboxes and seeded your own Postgres with a JSON fixture set. It was great. It was &lt;em&gt;your&lt;/em&gt; data, shaped how you needed it.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;dateutil.relativedelta(months=-3)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Static fixtures don't stay still. They rot in place, which is worse, because a frozen sandbox is at least honestly frozen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs this hides
&lt;/h2&gt;

&lt;p&gt;This is the actual argument. A corpus that never changes cannot produce a failing test for any of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_since&lt;/code&gt; boundary conditions.&lt;/strong&gt; Inclusive or exclusive at the timestamp? Server clock vs. your clock? Resources written during your poll window — do you get them twice, or never?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay idempotency.&lt;/strong&gt; The same resource delivered twice. Do you upsert on a stable identifier, or do you now have two Patients?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out-of-order arrival.&lt;/strong&gt; The &lt;code&gt;Observation&lt;/code&gt; lands before the &lt;code&gt;Encounter&lt;/code&gt; it references. Does your ingest dangle the reference, drop the resource, or 500?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version conflicts.&lt;/strong&gt; A resource you cached is updated upstream. Does your &lt;code&gt;If-Match&lt;/code&gt; on &lt;code&gt;meta.versionId&lt;/code&gt; do the right thing, or do you silently clobber?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook retry dedup.&lt;/strong&gt; The delivery times out on your end, the sender retries, you process it twice. Does anything downstream double-count?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination across a moving result set.&lt;/strong&gt; You're on page 3 of 40 when new resources land. Do you skip records, repeat them, or loop?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache invalidation.&lt;/strong&gt; How would you even know? Nothing has ever invalidated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is a &lt;em&gt;time&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built instead
&lt;/h2&gt;

&lt;p&gt;mock.health's sandbox runs a clock.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;timeline walker&lt;/strong&gt; 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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Computes model time as &lt;code&gt;anchor_model + (now - anchor_real) * speed&lt;/code&gt;, then finds every event-group whose anchor has come due.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-dates the group&lt;/strong&gt; 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. &lt;code&gt;birthDate&lt;/code&gt; is never shifted — patients don't get younger.&lt;/li&gt;
&lt;li&gt;Writes it as a transaction bundle, PUT with client-assigned UUIDs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third step is the one that makes the whole thing work, and it's almost boring: a real write produces a real &lt;code&gt;meta.lastUpdated&lt;/code&gt;. Which means &lt;code&gt;_lastUpdated&lt;/code&gt; polling and bulk-export deltas return genuinely fresh results — on &lt;code&gt;/fhir&lt;/code&gt; and on every &lt;code&gt;/v/*&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I found the same bug in my own server
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;$everything&lt;/code&gt; with a future &lt;code&gt;_since&lt;/code&gt;, confirm you get an empty Bundle.&lt;/p&gt;

&lt;p&gt;I got the entire compartment. 122 resources, none of them newer than a date in 2099.&lt;/p&gt;

&lt;p&gt;HAPI accepts &lt;code&gt;_since&lt;/code&gt; on &lt;code&gt;Patient/$everything&lt;/code&gt; and ignores it. Not rejects: &lt;em&gt;ignores&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;supposed&lt;/em&gt; to work. It is very good software. And this is in it.&lt;/p&gt;

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

&lt;p&gt;The obvious defense is that &lt;code&gt;_since&lt;/code&gt; is optional. And it is — sort of. In the R4 &lt;code&gt;OperationDefinition&lt;/code&gt; the parameter is &lt;code&gt;0..1&lt;/code&gt;, which means a &lt;em&gt;client&lt;/em&gt; 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:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HAPI declares the canonical operation, not a reduced one.&lt;/strong&gt; Ask its CapabilityStatement what &lt;code&gt;$everything&lt;/code&gt; is and it answers with HL7's own definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;everything  -&amp;gt;  http://hl7.org/fhir/OperationDefinition/Patient-everything
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every other operation it implements points at a HAPI-local &lt;code&gt;OperationDefinition&lt;/code&gt; describing what HAPI actually does — &lt;code&gt;.../OperationDefinition/Multi-its-meta&lt;/code&gt;, &lt;code&gt;.../Global-is-diff&lt;/code&gt;. For &lt;code&gt;$everything&lt;/code&gt; it points at the spec's definition, the one that contains &lt;code&gt;_since&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It filters everything else on that same operation.&lt;/strong&gt; &lt;code&gt;_type&lt;/code&gt; works. &lt;code&gt;_lastUpdated&lt;/code&gt; works on ordinary type-level searches. The date filter just never got wired into &lt;code&gt;$everything&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the response says the filter ran.&lt;/strong&gt; FHIR's mechanism for telling a client which parameters a server actually honored is the Bundle's &lt;code&gt;self&lt;/code&gt; link. Here's what comes back for a cursor in 2099:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;self: .../Patient/{id}/$everything?_count=2&amp;amp;_since=2099-01-01T00%3A00%3A00Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parameter is echoed. No &lt;code&gt;OperationOutcome&lt;/code&gt;, no warning, no &lt;code&gt;code: not-supported&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;I filed it upstream — &lt;a href="https://github.com/hapifhir/hapi-fhir/issues/8039" rel="noopener noreferrer"&gt;hapifhir/hapi-fhir#8039&lt;/a&gt;. 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:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On a static sandbox, "correctly returned nothing" and "ignored your filter" are the same response.&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.mock.health/fhir/Patient/&lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;everything?_since=2099-01-01T00:00:00Z"&lt;/span&gt;
&lt;span class="c"&gt;# {"resourceType":"Bundle","type":"searchset","total":0,"entry":[]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things fell out of doing it, and they're all the boundary conditions from the list above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The bound is exclusive&lt;/strong&gt;, &lt;code&gt;lastUpdated &amp;gt; cursor&lt;/code&gt;, because R4 says "updated &lt;em&gt;after&lt;/em&gt;." 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 &lt;code&gt;id&lt;/code&gt; + &lt;code&gt;versionId&lt;/code&gt;. Our own &lt;code&gt;$export&lt;/code&gt; uses an inclusive bound, which is an inconsistency I'd rather write down than quietly paper over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A malformed &lt;code&gt;_since&lt;/code&gt; returns 400&lt;/strong&gt; instead of being ignored. Silently widening to "everything" is how this class of bug hides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A truncated delta says so in-band&lt;/strong&gt;, as an &lt;code&gt;OperationOutcome&lt;/code&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the test you couldn't write, you can write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Snapshot&lt;/span&gt;
curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.mock.health/fhir/Patient/&lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;everything?_since=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'1 hour ago'&lt;/span&gt; +%FT%TZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Wait. Ask again. Different answer.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this doesn't give you
&lt;/h2&gt;

&lt;p&gt;Three honest limits, because a living sandbox is not a production environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's still synthetic.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The clock is compressed, not real-time.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FHIR Subscriptions aren't on the vendor twins, deliberately.&lt;/strong&gt; You get them on &lt;code&gt;/fhir&lt;/code&gt;. You don't get them on &lt;code&gt;/v/epic&lt;/code&gt; or &lt;code&gt;/v/cerner&lt;/code&gt;, 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Free tier gets &lt;code&gt;_since&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Testing FHIR Integrations Without a Hospital</title>
      <dc:creator>mock health</dc:creator>
      <pubDate>Tue, 14 Apr 2026 21:24:38 +0000</pubDate>
      <link>https://dev.to/mockhealth/testing-fhir-integrations-without-a-hospital-i2k</link>
      <guid>https://dev.to/mockhealth/testing-fhir-integrations-without-a-hospital-i2k</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr3rnf709xqttfykcetss.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr3rnf709xqttfykcetss.png" alt=" "&gt;&lt;/a&gt;&lt;br&gt;
You're building a FHIR integration. You need to test it against data that looks like what a hospital actually produces.&lt;/p&gt;

&lt;p&gt;You don't have access to a hospital.&lt;/p&gt;

&lt;p&gt;This is the catch-22 that every FHIR startup lives in for 6-12 months. You can't get production EHR access without a working, tested integration. You can't build one without production-quality data. Epic's app marketplace review takes 2-4 months. Oracle Health takes 3-6. And all of them want evidence that your software works &lt;em&gt;before&lt;/em&gt; they give you the data to prove it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Testing Pyramid for FHIR
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Tier 1: Parse and Validate
&lt;/h3&gt;

&lt;p&gt;Does your FHIR output conform to US Core profiles? You don't need a server for this. The &lt;a href="https://hapifhir.io/hapi-fhir/docs/validation/instance_validator.html" rel="noopener noreferrer"&gt;HAPI FHIR Validator&lt;/a&gt; runs locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;java &lt;span class="nt"&gt;-jar&lt;/span&gt; validator_cli.jar patient-bundle.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-ig&lt;/span&gt; hl7.fhir.us.core#6.1.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-profile&lt;/span&gt; http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this in CI on every commit. It catches malformed resources, missing required fields, and profile violations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tier 2: Integration
&lt;/h3&gt;

&lt;p&gt;This requires a running FHIR server with SMART on FHIR auth. You need to test the full request lifecycle: discovery, authorization, token exchange, scoped queries.&lt;/p&gt;

&lt;p&gt;Things that break at this tier and nowhere else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your search uses &lt;code&gt;Observation?category=laboratory&lt;/code&gt; but the server indexes it as &lt;code&gt;Observation?category=http://terminology.hl7.org/CodeSystem/observation-category|laboratory&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Your SMART scopes request &lt;code&gt;patient/Observation.read&lt;/code&gt; but the server only grants &lt;code&gt;patient/Observation.rs&lt;/code&gt; — read and search are separate grants&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Patient/$everything&lt;/code&gt; returns 2,000 entries with pagination links your client doesn't follow&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tier 3: Realistic
&lt;/h3&gt;

&lt;p&gt;This is the tier most teams skip. You need patients that look like real patients:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you're testing&lt;/th&gt;
&lt;th&gt;What the data needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lab trending UI&lt;/td&gt;
&lt;td&gt;3+ years of longitudinal labs with reference ranges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medication reconciliation&lt;/td&gt;
&lt;td&gt;8-12 active medications with start dates and dosages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Problem list display&lt;/td&gt;
&lt;td&gt;5-15 active conditions with SNOMED coding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prior auth workflow&lt;/td&gt;
&lt;td&gt;Complex patients who actually get denied&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your Tier 3 data is a single patient named "Test Cancer" with one condition, your tests aren't testing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your Options
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Option 1: Local HAPI + Synthea
&lt;/h3&gt;

&lt;p&gt;You've probably already done this. HAPI in Docker, a handful of Synthea patients, maybe a script that generates 10 bundles.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 hapiproject/hapi:latest
&lt;span class="nb"&gt;cd &lt;/span&gt;synthea &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./run_synthea &lt;span class="nt"&gt;-p&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;When this is enough:&lt;/strong&gt; Early prototyping, Tier 1 validation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it isn't:&lt;/strong&gt; No SMART auth. Synthea defaults produce patients with single conditions and no clinical depth. At some point the test data pipeline becomes its own project — one you maintain alongside the product you're actually building.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: Vendor Sandboxes
&lt;/h3&gt;

&lt;p&gt;Open Epic, Oracle Health Code, SMART Health IT Sandbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When this is enough:&lt;/strong&gt; Testing OAuth against a real vendor's auth server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it isn't:&lt;/strong&gt; Open Epic gives you 8 patients with sparse data. No comorbidity patterns. No longitudinal labs. No imaging. No clinical notes. &lt;a href="https://mock.health/blog/fhir-sandbox-problem" rel="noopener noreferrer"&gt;We wrote a whole post about this.&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 3: Clinically Realistic Sandbox
&lt;/h3&gt;

&lt;p&gt;A FHIR server with SMART auth and patients generated from real clinical patterns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.mock.health/fhir/Condition?patient&lt;span class="o"&gt;=&lt;/span&gt;example &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.entry[].resource.code.coding[0].display'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Type 2 diabetes mellitus"
"Essential hypertension"
"Chronic kidney disease, stage 3"
"Hyperlipidemia"
"Diabetic retinopathy"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These conditions travel together because the generation engine learned that pattern from real CMS claims data. Compare to Open Epic: &lt;code&gt;"Pain in throat"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changes When You Get Production Access
&lt;/h2&gt;

&lt;p&gt;Your sandbox-tested code won't ship unmodified. You'll encounter vendor-specific extensions, data quality variance (&lt;a href="https://mock.health/blog/these-dumpster-contents-are-private" rel="noopener noreferrer"&gt;plan for trash&lt;/a&gt;), and an opaque app review process.&lt;/p&gt;

&lt;p&gt;But the architecture you built against a sandbox will survive. The specific data handling will need adaptation. That's normal — sandbox testing builds the 90% that doesn't depend on vendor-specific behavior.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;HAPI Validator in CI&lt;/td&gt;
&lt;td&gt;Malformed resources, profile violations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;SMART-enabled sandbox&lt;/td&gt;
&lt;td&gt;OAuth bugs, search parameter issues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Realistic synthetic data&lt;/td&gt;
&lt;td&gt;Logic errors with complex patients&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run Tier 1 on every commit. Run Tier 2 when you change auth or query logic. Run Tier 3 before every demo and before you apply for production access.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://mock.health" rel="noopener noreferrer"&gt;mock.health&lt;/a&gt; — SMART-enabled FHIR sandbox with clinical density to get through Tier 3 testing. &lt;a href="https://mock.health/login" rel="noopener noreferrer"&gt;API key in 60 seconds →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fhir</category>
      <category>healthtech</category>
      <category>api</category>
      <category>testing</category>
    </item>
    <item>
      <title>Three Ways to Build a Multi-Tenant FHIR Server</title>
      <dc:creator>mock health</dc:creator>
      <pubDate>Sun, 12 Apr 2026 23:35:03 +0000</pubDate>
      <link>https://dev.to/mockhealth/three-ways-to-build-a-multi-tenant-fhir-server-2jd</link>
      <guid>https://dev.to/mockhealth/three-ways-to-build-a-multi-tenant-fhir-server-2jd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwkvqm1bf09f0e8274b4j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwkvqm1bf09f0e8274b4j.png" alt=" " width="800" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You're building a FHIR platform that serves multiple customers. Each one needs isolated data through the same API. There's no best practice for this — Darren Devitt's &lt;a href="https://darrendevitt.com" rel="noopener noreferrer"&gt;&lt;em&gt;FHIR Architecture Decisions&lt;/em&gt;&lt;/a&gt; calls multi-tenancy a "deal breaker" requirement that can eliminate entire classes of FHIR servers.&lt;/p&gt;

&lt;p&gt;Three models exist. We've built two of them in production with &lt;a href="https://github.com/hapifhir/hapi-fhir" rel="noopener noreferrer"&gt;HAPI FHIR&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model 1: Separate Database Per Tenant
&lt;/h2&gt;

&lt;p&gt;Each tenant gets their own database schema — or for true physical isolation, their own database host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; Strongest isolation. Independent scaling. &lt;code&gt;DROP SCHEMA&lt;/code&gt; to delete a tenant. Compliance teams love it (especially separate hosts).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What breaks:&lt;/strong&gt; You're managing N connection pools, N migrations, N backups. HAPI's HikariCP defaults to 10 connections — we exhausted the pool with 8 concurrent workers and left HAPI in a zombie state. Cold starts per tenant add 30-45 seconds when a database hasn't been accessed recently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; Regulations mandate physical separation. Tenants are large enough to justify the overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model 2: Shared Database, Partitioned Tables
&lt;/h2&gt;

&lt;p&gt;One database, but every resource row gets a &lt;code&gt;PARTITION_ID&lt;/code&gt; column. HAPI supports this natively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;partitioning&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;database_partition_mode_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;request_tenant_partitioning_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;allow_references_across_partitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating a partition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /fhir/$partition-management-create-partition
{"resourceType": "Parameters", "parameter": [
  {"name": "id", "valueInteger": 101},
  {"name": "name", "valueCode": "tenant_acme"}
]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; Logical isolation at the storage layer. One database to manage. Cross-partition references for shared data (practitioner directories, formularies).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What breaks:&lt;/strong&gt; HAPI bugs surface here. &lt;a href="https://github.com/hapifhir/hapi-fhir/issues/1099" rel="noopener noreferrer"&gt;#1099&lt;/a&gt;: &lt;code&gt;patient=UUID&lt;/code&gt; triggers "Non-unique ID" errors — fix by rewriting to &lt;code&gt;patient:Patient._id=UUID&lt;/code&gt;. &lt;a href="https://github.com/hapifhir/hapi-fhir/issues/6665" rel="noopener noreferrer"&gt;#6665&lt;/a&gt;: bulk export background jobs lose partition context entirely — we rebuilt the Bulk Data Access IG in Python.&lt;/p&gt;

&lt;p&gt;No built-in access control. HAPI doesn't know who's calling — your API gateway handles auth and routing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; Many tenants, moderate data volumes. You want logical isolation without N databases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model 3: Shared Database, Tag-Based Filtering
&lt;/h2&gt;

&lt;p&gt;One database, no partitions. Every resource gets a &lt;code&gt;meta.tag&lt;/code&gt; for its tenant. Your gateway appends &lt;code&gt;_tag=tenant_acme&lt;/code&gt; to every query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;extra_params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_tag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://yourapp.com/tenant|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; Simplest to implement. Zero overhead per tenant. Works with any FHIR server, not just HAPI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What breaks:&lt;/strong&gt; No real isolation — the boundary is your gateway's tag-filtering logic. A missed &lt;code&gt;_tag&lt;/code&gt; parameter on one endpoint leaks data across tenants.&lt;/p&gt;

&lt;p&gt;Worse: HAPI ignores &lt;code&gt;_tag&lt;/code&gt; on instance reads. &lt;code&gt;GET /fhir/Patient/abc-123&lt;/code&gt; returns the resource regardless of tags. You need post-fetch verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;resource_tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;resource_tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intersection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowed_tags&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; Prototyping. Internal tools. Small deployments. A starting point before migrating to partitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Choose
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Separate DB&lt;/th&gt;
&lt;th&gt;Partitions&lt;/th&gt;
&lt;th&gt;Tags&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Isolation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Strongest&lt;/td&gt;
&lt;td&gt;Logical (storage)&lt;/td&gt;
&lt;td&gt;Logical (query)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compliance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Happy&lt;/td&gt;
&lt;td&gt;Usually OK&lt;/td&gt;
&lt;td&gt;Nervous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ops cost per tenant&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Performance isolation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Shared DB&lt;/td&gt;
&lt;td&gt;Shared everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HAPI bugs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fewer&lt;/td&gt;
&lt;td&gt;More&lt;/td&gt;
&lt;td&gt;Fewer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Works with any server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;HAPI-specific&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Instance read safety&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Inherent&lt;/td&gt;
&lt;td&gt;Inherent&lt;/td&gt;
&lt;td&gt;Post-fetch check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The direction of migration matters: &lt;strong&gt;you can move from tags → partitions → separate databases, but not easily the other way.&lt;/strong&gt; Start with the simplest model that meets your compliance requirements.&lt;/p&gt;

&lt;p&gt;Whichever model you choose, the FHIR server handles storage. Auth, routing, and access control are your gateway's job. &lt;a href="https://mock.health/blog/fhir-api-gateway" rel="noopener noreferrer"&gt;We wrote about what that gateway looks like.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://mock.health" rel="noopener noreferrer"&gt;mock.health&lt;/a&gt; handles multi-tenant isolation so you can focus on your integration. Isolated FHIR data with SMART auth — no partition management required. &lt;a href="https://mock.health/login" rel="noopener noreferrer"&gt;Try it free →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fhir</category>
      <category>healthtech</category>
      <category>architecture</category>
      <category>database</category>
    </item>
    <item>
      <title>Building a FHIR API Gateway: What HAPI Won't Do for You</title>
      <dc:creator>mock health</dc:creator>
      <pubDate>Fri, 10 Apr 2026 22:56:33 +0000</pubDate>
      <link>https://dev.to/mockhealth/building-a-fhir-api-gateway-what-hapi-wont-do-for-you-1pll</link>
      <guid>https://dev.to/mockhealth/building-a-fhir-api-gateway-what-hapi-wont-do-for-you-1pll</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv9zdu6qvdkddt2ivj3h4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv9zdu6qvdkddt2ivj3h4.png" alt="HAPI stairs" width="800" height="998"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;HAPI is a FHIR storage engine. It validates resources, indexes search parameters, and returns Bundles. What it does not do: authenticate users, enforce per-tenant access control, generate correct URLs behind a reverse proxy, or stream large responses without buffering.&lt;/p&gt;

&lt;p&gt;We run FastAPI in front of HAPI on Google Cloud Run. Here are the five things the gateway does that HAPI can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Auth Injection: One Hook, Every Request
&lt;/h2&gt;

&lt;p&gt;In production, HAPI sits behind Cloud Run's IAM layer. Every request needs a GCP identity token. The pattern: an httpx event hook that fires before every outbound request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_inject_hapi_auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_id_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audience_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug we hit: auth injection was per-route. The main proxy had it. Portal routes didn't. Everything worked locally (no IAM). Four endpoints returned 403 in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Auth belongs on the HTTP client, not on individual routes. One hook, one place, every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The &lt;code&gt;--proxy-headers&lt;/code&gt; Trap
&lt;/h2&gt;

&lt;p&gt;This will save you a day if you're running FastAPI behind any load balancer.&lt;/p&gt;

&lt;p&gt;Cloud Run terminates TLS. Your app gets HTTP. The load balancer passes &lt;code&gt;X-Forwarded-Proto: https&lt;/code&gt;. Uvicorn ignores this by default. So &lt;code&gt;request.base_url&lt;/code&gt; returns &lt;code&gt;http://&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cascade:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SMART discovery advertises &lt;code&gt;http://&lt;/code&gt; token endpoint&lt;/li&gt;
&lt;li&gt;Client POSTs auth code to &lt;code&gt;http://&lt;/code&gt; URL&lt;/li&gt;
&lt;li&gt;Cloud Run 302 redirects HTTP → HTTPS&lt;/li&gt;
&lt;li&gt;302 converts POST to GET (per HTTP spec)&lt;/li&gt;
&lt;li&gt;Token endpoint gets GET → &lt;strong&gt;405 Method Not Allowed&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nine places in our code use &lt;code&gt;request.base_url&lt;/code&gt;. All nine broke. The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000",&lt;/span&gt;
     "--proxy-headers", "--forwarded-allow-ips", "*"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're running uvicorn behind any reverse proxy and generating URLs from &lt;code&gt;request.base_url&lt;/code&gt;, you need these flags. You won't catch this locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Tag-Based Access Control
&lt;/h2&gt;

&lt;p&gt;HAPI has no per-user access control. Our gateway appends &lt;code&gt;_tag&lt;/code&gt; parameters to scope searches by tenant/dataset.&lt;/p&gt;

&lt;p&gt;The gotcha: &lt;strong&gt;HAPI ignores &lt;code&gt;_tag&lt;/code&gt; on instance reads.&lt;/strong&gt; &lt;code&gt;GET /fhir/Patient/abc-123&lt;/code&gt; returns the resource regardless of tags. Our gateway does post-fetch verification — fetch the resource, check its tags, return 404 if they don't match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;resource_tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;resource_tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intersection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowed_tags&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not ideal — the resource is fetched and discarded. But HAPI doesn't support tag filtering on instance reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Streaming Large Responses
&lt;/h2&gt;

&lt;p&gt;A FHIR &lt;code&gt;$everything&lt;/code&gt; can return megabytes. Don't buffer it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StreamingResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aiter_bytes&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/fhir+json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: if HAPI returns 503, detect it &lt;em&gt;before&lt;/em&gt; starting the stream. Once you've started a &lt;code&gt;StreamingResponse&lt;/code&gt;, you can't change the status code.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Catch-All Route Ordering Trap
&lt;/h2&gt;

&lt;p&gt;Our FHIR proxy has a catch-all: &lt;code&gt;/fhir/{path:path}&lt;/code&gt;. This matches everything — including &lt;code&gt;/fhir/Patient/$export&lt;/code&gt; and &lt;code&gt;/fhir/DocumentReference/$docref&lt;/code&gt;, which have dedicated handlers.&lt;/p&gt;

&lt;p&gt;If the catch-all mounts first, those routes are unreachable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Order matters
&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include_router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bulk_export_router&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# most specific
&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include_router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docref_router&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include_router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fhir_router&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# catch-all last
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sounds obvious. Costs an afternoon when you add a new router six months later and forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the Gateway First
&lt;/h2&gt;

&lt;p&gt;If you're deploying a FHIR server to production, build the gateway layer before you build features on top. Auth, URL generation, access control, streaming, and route safety affect every endpoint you'll add later. The FHIR server stores data. The gateway decides who sees it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://mock.health" rel="noopener noreferrer"&gt;mock.health&lt;/a&gt; handles the gateway — SMART auth, access control, streaming — so you can focus on your application. &lt;a href="https://mock.health/login" rel="noopener noreferrer"&gt;Try it free →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fhir</category>
      <category>healthtech</category>
      <category>python</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>The Contents of That Dumpster Are Private</title>
      <dc:creator>mock health</dc:creator>
      <pubDate>Mon, 06 Apr 2026 23:09:23 +0000</pubDate>
      <link>https://dev.to/mockhealth/the-contents-of-that-dumpster-are-private-4dok</link>
      <guid>https://dev.to/mockhealth/the-contents-of-that-dumpster-are-private-4dok</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foigjap1s4r08csr4fm29.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foigjap1s4r08csr4fm29.png" alt=" " width="800" height="693"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nikhil Krishnan wrote &lt;a href="https://www.outofpocket.health/p/open-source-in-healthcare-is-an-opportunity" rel="noopener noreferrer"&gt;a great piece recently&lt;/a&gt; arguing that open source is healthcare's moment. The business models exist (Red Hat, GitLab). AI is lowering the contribution barrier. The ecosystem is ready.&lt;/p&gt;

&lt;p&gt;He's right. But there's a punchline missing from the open-standards conversation that anyone who's actually &lt;em&gt;built&lt;/em&gt; against healthcare APIs knows intimately:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The standard exists. Nobody follows it the same way.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;FHIR (Fast Healthcare Interoperability Resources) is the anointed standard. ONC mandates it. CMS requires it for payer Patient Access APIs. Every major EHR vendor will tell you they support it. And technically, they do — the way a restaurant "supports" vegetarians by offering a side salad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same Patient, Different Reality
&lt;/h2&gt;

&lt;p&gt;Flexpa recently published a fascinating comparison: &lt;a href="https://www.flexpa.com/blog/my-health-data-across-two-apis" rel="noopener noreferrer"&gt;the same patient's health data pulled through two different API pathways&lt;/a&gt;. The results should make anyone building healthcare software nervous:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TEFCA path&lt;/strong&gt;: 192 FHIR resources, but only &lt;strong&gt;1 condition&lt;/strong&gt; — "pain in throat"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ONC g(10) path&lt;/strong&gt;: 166 resources, &lt;strong&gt;11 conditions&lt;/strong&gt; including rhinitis — but missed a documented cat dander allergy entirely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same patient. Same standard. Wildly different clinical picture. If you were building a care coordination app, which version of this patient would you trust?&lt;/p&gt;

&lt;p&gt;This isn't an edge case. It's the norm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spec Says One Thing. Production Says Another.
&lt;/h2&gt;

&lt;p&gt;The January 2026 deadline for USCDI v3 and US Core 6.1.0 was supposed to fix this. Certified health IT modules must now support the latest FHIR profiles with richer data elements. On paper, progress.&lt;/p&gt;

&lt;p&gt;In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Major state Medicaid agencies&lt;/strong&gt; — Arizona, Colorado, Illinois, Indiana, Massachusetts, New York, Pennsylvania, Texas — remain non-compliant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No BCBS plan operated by HCSC&lt;/strong&gt; has successfully had a patient complete authorization and FHIR data retrieval&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;53 payers&lt;/strong&gt; list refresh token support in their SMART configurations but &lt;em&gt;don't actually issue refresh tokens&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UnitedHealthcare&lt;/strong&gt; has nearly 100 patients who simply cannot access their own data through the mandated API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These aren't obscure edge cases. These are the largest payers and state programs in the country.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Normalization Tax
&lt;/h2&gt;

&lt;p&gt;Even when the APIs work, the data that comes back requires heavy lifting before it's usable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://zushealth.com/pipe-dreams-building-smarter-healthcare-data-networks-for-better-care/" rel="noopener noreferrer"&gt;Zus Health reports&lt;/a&gt; that pulling records from clinical networks yields about 200 documents per patient, mapping to over 1,000 raw FHIR resources. Conditions arrive as a grab bag of ICD-9, ICD-10, and SNOMED codes — sometimes all three for the same condition. Without normalization, deduplication, and enrichment, the volume is pure noise.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.particlehealth.com/blog/ensuring-data-quality-part-2" rel="noopener noreferrer"&gt;Particle Health&lt;/a&gt; built an entirely proprietary CCDA-to-FHIR converter because open source libraries didn't meet their quality bar.&lt;/p&gt;

&lt;p&gt;This is the dirty secret: &lt;strong&gt;an entire category of healthcare infrastructure companies exists primarily to clean up the mess that "standardized" data exchange creates.&lt;/strong&gt; The standard is the floor, not the ceiling — and the floor has holes in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan for Trash
&lt;/h2&gt;

&lt;p&gt;FHIR is a good standard. US Core is a good implementation guide. The ONC keeps tightening the spec — USCDI v3, US Core 6.1.0, SMART App Launch 2.0. But tighter specs only help if implementations actually conform. Right now, "FHIR-compliant" is a checkbox on a certification form, not a meaningful guarantee that the data you receive will be complete, consistent, or even parseable.&lt;/p&gt;

&lt;p&gt;So here's the engineering takeaway: &lt;strong&gt;stop hoping for clean data. Plan for trash.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every application that consumes FHIR from external systems needs a validation layer that isn't optional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Profile validation on ingest.&lt;/strong&gt; Every resource that crosses your system boundary gets validated against the US Core profile you expect. Not just schema validation — profile-level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completeness checks per data class.&lt;/strong&gt; USCDI defines 22 data classes. When you pull a patient's record, how many are actually populated? Track and surface that distinction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated regression testing against real-world variance.&lt;/strong&gt; Your integration tests shouldn't run against a single pristine FHIR server. They should run against data that looks like what production actually sends — missing fields, mixed code systems, DSTU2 holdovers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The companies actually moving the needle in interoperability — Flexpa, Zus, Particle, Onyx — all learned this the hard way. They built proprietary normalization, deduplication, and validation pipelines because the alternative was shipping broken products. The standard isn't bad. Conformance is just unreliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dumpster Metaphor, Extended
&lt;/h2&gt;

&lt;p&gt;The meme says the contents of the dumpster are private. That's the HIPAA joke, and it's funny. But the deeper joke is:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Even if you get permission to open the dumpster, what's inside is still trash.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Don't wait for the spec to get stricter, or for ONC enforcement to get teeth, or for vendors to suddenly care about data quality. Build like you know the dumpster is full of trash — because it is — and validate everything that comes out of it.&lt;/p&gt;

&lt;p&gt;Which means your test data needs to look like what production actually sends — not pristine sandbox patients with perfect coding. If your integration tests pass against clean data and break against the dumpster, your tests are lying to you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://mock.health" rel="noopener noreferrer"&gt;mock.health&lt;/a&gt; generates FHIR data with the clinical density and variance you'll see in the real world. &lt;a href="https://mock.health" rel="noopener noreferrer"&gt;Free tier, no sales call →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fhir</category>
      <category>healthtech</category>
      <category>api</category>
      <category>interoperability</category>
    </item>
    <item>
      <title>How to Make Claude Write Valid Synthea Modules</title>
      <dc:creator>mock health</dc:creator>
      <pubDate>Fri, 03 Apr 2026 22:24:49 +0000</pubDate>
      <link>https://dev.to/mockhealth/how-to-make-claude-write-valid-synthea-modules-3adc</link>
      <guid>https://dev.to/mockhealth/how-to-make-claude-write-valid-synthea-modules-3adc</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F23xh3b1u7z1dkpygzvyw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F23xh3b1u7z1dkpygzvyw.png" alt=" " width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/synthetichealth/synthea" rel="noopener noreferrer"&gt;Synthea&lt;/a&gt; has 85 disease modules. Each one is a JSON state machine that generates encounters, conditions, labs, medications, and procedures for a specific disease. If you need a condition Synthea doesn't cover — celiac disease, migraine, GERD, whatever — you author a new module.&lt;/p&gt;

&lt;p&gt;The module format is learnable. The hard part is the medical codes.&lt;/p&gt;

&lt;p&gt;Every module embeds SNOMED codes for conditions, LOINC codes for labs, and RxNorm codes for medications. Ask an LLM to write a celiac disease module and it'll generate SNOMED code &lt;code&gt;396331005&lt;/code&gt;. That's correct. Ask it for a duodenal biopsy and it might generate &lt;code&gt;12866006&lt;/code&gt;. Looks right. Validates as a real SNOMED code. It's actually pneumococcal vaccination.&lt;/p&gt;

&lt;p&gt;You can't tell a valid code from a hallucinated one by looking at it. The only way to know is to check it against a terminology server. This post teaches that workflow, and we published a &lt;a href="https://github.com/mock-health/samples/tree/main/synthea-module-skill" rel="noopener noreferrer"&gt;Claude Code skill&lt;/a&gt; that automates it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You'd Want Custom Modules
&lt;/h2&gt;

&lt;p&gt;We generated 10,000 patients from a fresh Synthea clone and compared against CDC benchmarks:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0njadgxk9rf1snxzna2m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0njadgxk9rf1snxzna2m.png" alt="Synthea vs CDC condition prevalence" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Coronary heart disease: 0%. Alzheimer's: 0%. Entire disease categories missing from 10,000 patients. Each module runs independently, so conditions don't interact the way real diseases do. We wrote about this in depth in &lt;a href="https://mock.health/blog/how-we-validate-synthetic-data" rel="noopener noreferrer"&gt;how we validate synthetic data&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy6471x715cy06eirnof5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy6471x715cy06eirnof5.png" alt="Conditions per patient by age bracket" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An average 80+ year-old Synthea patient has 74 active conditions. The top 1% of real Medicare patients have about 8. Most are social determinants and administrative codes, not diseases.&lt;/p&gt;

&lt;p&gt;If your use case needs a disease that Synthea's 85 modules don't cover, you're authoring a module.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Module Format in 60 Seconds
&lt;/h2&gt;

&lt;p&gt;A Synthea module is a JSON file with a &lt;code&gt;name&lt;/code&gt;, a &lt;code&gt;states&lt;/code&gt; object, and a &lt;code&gt;gmf_version&lt;/code&gt;. Each state has a type and a transition. Here's the smallest useful module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Condition"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"states"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Initial"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Initial"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"distributed_transition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"transition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Onset"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"transition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Terminal"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Onset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ConditionOnset"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"codes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SNOMED-CT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"??????"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"??????"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"direct_transition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Terminal"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Terminal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Terminal"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gmf_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;??????&lt;/code&gt; is the problem. What SNOMED code goes there?&lt;/p&gt;

&lt;p&gt;State types you'll use: &lt;code&gt;Initial&lt;/code&gt;, &lt;code&gt;Terminal&lt;/code&gt;, &lt;code&gt;Encounter&lt;/code&gt;/&lt;code&gt;EncounterEnd&lt;/code&gt;, &lt;code&gt;ConditionOnset&lt;/code&gt;/&lt;code&gt;ConditionEnd&lt;/code&gt;, &lt;code&gt;MedicationOrder&lt;/code&gt;/&lt;code&gt;MedicationEnd&lt;/code&gt;, &lt;code&gt;Observation&lt;/code&gt;, &lt;code&gt;Procedure&lt;/code&gt;, &lt;code&gt;Guard&lt;/code&gt;, &lt;code&gt;Delay&lt;/code&gt;, &lt;code&gt;SetAttribute&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Transitions: &lt;code&gt;direct_transition&lt;/code&gt; (always), &lt;code&gt;conditional_transition&lt;/code&gt; (if-then), &lt;code&gt;distributed_transition&lt;/code&gt; (probabilistic), &lt;code&gt;complex_transition&lt;/code&gt; (conditions + probabilities).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Code Problem
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;What it codes&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SNOMED-CT&lt;/td&gt;
&lt;td&gt;Conditions, procedures, findings&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;396331005&lt;/code&gt; = Celiac disease&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LOINC&lt;/td&gt;
&lt;td&gt;Lab results, vital signs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;31017-7&lt;/code&gt; = tTG IgA antibody&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RxNorm&lt;/td&gt;
&lt;td&gt;Medications&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;310325&lt;/code&gt; = Ferrous sulfate 325mg&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;LLMs pattern-match these codes from training data. They don't look them up. For common conditions, usually right. For anything less common, the LLM generates a plausible number that might not exist.&lt;/p&gt;

&lt;p&gt;The fix: &lt;a href="https://tx.fhir.org" rel="noopener noreferrer"&gt;tx.fhir.org&lt;/a&gt;, a free public FHIR terminology server. No account needed. One curl call validates any code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://tx.fhir.org/r4/CodeSystem/&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;validate-code?&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
system=http://snomed.info/sct&amp;amp;code=396331005"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.parameter[] | select(.name=="result" or .name=="display")'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"valueBoolean"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"valueString"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Coeliac disease"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And to &lt;em&gt;find&lt;/em&gt; the right code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://tx.fhir.org/r4/ValueSet/&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;expand?&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
url=http://snomed.info/sct?fhir_vs&amp;amp;filter=celiac+disease&amp;amp;count=5"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.expansion.contains[] | {code, display}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what separates a working module from one that generates corrupt FHIR.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Skill
&lt;/h2&gt;

&lt;p&gt;We published a Claude Code skill that automates this workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nb"&gt;install &lt;/span&gt;github:mock-health/samples/synthea-module-skill
claude &lt;span class="s2"&gt;"/synthea create a celiac disease module"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skill follows six steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check existing modules&lt;/strong&gt; — avoids duplicating what's already there&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Research the condition&lt;/strong&gt; — prevalence, diagnostic criteria, treatment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look up every code&lt;/strong&gt; — validates against tx.fhir.org before writing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate module JSON&lt;/strong&gt; — following Synthea's exact schema&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build and test&lt;/strong&gt; — &lt;code&gt;./gradlew build -x test&lt;/code&gt; + &lt;code&gt;./run_synthea -m &amp;lt;name&amp;gt; -p 1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspect output&lt;/strong&gt; — checks the generated FHIR bundle&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://github.com/mock-health/samples/tree/main/synthea-module-skill/SKILL.md" rel="noopener noreferrer"&gt;SKILL.md&lt;/a&gt; contains the full module schema reference, code system mappings, grounding rules, and common pitfalls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working Example: Celiac Disease
&lt;/h2&gt;

&lt;p&gt;Every code validated against tx.fhir.org:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Display&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Celiac disease&lt;/td&gt;
&lt;td&gt;SNOMED-CT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;396331005&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Coeliac disease&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EGD&lt;/td&gt;
&lt;td&gt;SNOMED-CT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;76009000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Esophagogastroduodenoscopy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duodenal biopsy&lt;/td&gt;
&lt;td&gt;SNOMED-CT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;235261009&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Biopsy of duodenum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gluten free diet&lt;/td&gt;
&lt;td&gt;SNOMED-CT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;160671006&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gluten free diet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iron deficiency anemia&lt;/td&gt;
&lt;td&gt;SNOMED-CT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;87522002&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Iron deficiency anemia&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tTG IgA antibody&lt;/td&gt;
&lt;td&gt;LOINC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;31017-7&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tissue transglutaminase IgA Ab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ferritin&lt;/td&gt;
&lt;td&gt;LOINC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2276-4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ferritin [Mass/volume] in Serum or Plasma&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ferrous sulfate&lt;/td&gt;
&lt;td&gt;RxNorm&lt;/td&gt;
&lt;td&gt;&lt;code&gt;310325&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ferrous sulfate 325 MG Oral Tablet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The state machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Initial → Age_Guard (wait until age 2)
  → Prevalence_Check (monthly, age-stratified probability)
    → Symptom_Onset → Diagnostic_Encounter
      → tTG IgA test → Referral (2-6 weeks)
        → Endoscopy + Duodenal biopsy
          → Celiac_Diagnosis → Gluten-free diet
            → 50%: Iron deficiency → Ferrous sulfate
              → Annual monitoring (tTG IgA + ferritin)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The module uses &lt;code&gt;complex_transition&lt;/code&gt; for age-stratified onset — childhood and ages 30-50 get different probabilities. The &lt;a href="https://github.com/mock-health/samples/tree/main/synthea-module-skill/celiac_disease.json" rel="noopener noreferrer"&gt;full module JSON&lt;/a&gt; is about 200 lines. Drop it into &lt;code&gt;synthea/src/main/resources/modules/&lt;/code&gt; and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;synthea
./gradlew build &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nb"&gt;test&lt;/span&gt;
./run_synthea &lt;span class="nt"&gt;-m&lt;/span&gt; celiac_disease &lt;span class="nt"&gt;-p&lt;/span&gt; 10 &lt;span class="nt"&gt;-s&lt;/span&gt; 42
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.entry[].resource.resourceType'&lt;/span&gt; output/fhir/&lt;span class="k"&gt;*&lt;/span&gt;.json | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  For Deeper Work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/fhir&lt;/code&gt; Claude Code skill&lt;/strong&gt; — FHIR R4/R5, IGs, FSH, SMART on FHIR, validation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://inferno.healthit.gov/" rel="noopener noreferrer"&gt;Inferno&lt;/a&gt;&lt;/strong&gt; — ONC FHIR server compliance testing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://synthetichealth.github.io/module-builder/" rel="noopener noreferrer"&gt;Synthea Module Builder&lt;/a&gt;&lt;/strong&gt; — GUI for visual module authoring&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tx.fhir.org" rel="noopener noreferrer"&gt;tx.fhir.org&lt;/a&gt;&lt;/strong&gt; — public FHIR terminology server (free, no account)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;The module format is learnable. The vocabulary problem is solvable. Ground your codes, and don't trust any medical code an LLM generates from memory — including ours. We built &lt;a href="https://mock.health" rel="noopener noreferrer"&gt;mock.health&lt;/a&gt; because we hit the limits of module-level fixes. Population-level realism requires &lt;a href="https://mock.health/blog/how-we-validate-synthetic-data" rel="noopener noreferrer"&gt;a different architecture entirely&lt;/a&gt;. &lt;a href="https://mock.health" rel="noopener noreferrer"&gt;Free tier, API key in 60 seconds →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>healthtech</category>
      <category>fhir</category>
      <category>tutorial</category>
      <category>ai</category>
    </item>
    <item>
      <title>Build a SMART on FHIR App in 30 Minutes</title>
      <dc:creator>mock health</dc:creator>
      <pubDate>Thu, 26 Mar 2026 20:55:05 +0000</pubDate>
      <link>https://dev.to/mockhealth/build-a-smart-on-fhir-app-in-30-minutes-3g39</link>
      <guid>https://dev.to/mockhealth/build-a-smart-on-fhir-app-in-30-minutes-3g39</guid>
      <description>&lt;p&gt;SMART on FHIR is how apps talk to electronic health records. It's OAuth 2.0 with a few healthcare-specific conventions: a discovery document that tells your app where to authenticate, scopes that map to FHIR resource types, and a launch context that tells you which patient you're looking at.&lt;/p&gt;

&lt;p&gt;This tutorial follows &lt;a href="https://hl7.org/fhir/smart-app-launch/STU2.1/" rel="noopener noreferrer"&gt;SMART App Launch STU2.1&lt;/a&gt; — the current published standard. If you've built an OAuth login flow before, you already understand 80% of it. The other 20% is what this covers.&lt;/p&gt;

&lt;p&gt;We're going to build a standalone SMART app that authenticates against a FHIR server, fetches a patient's vital signs, and renders interactive charts — heart rate, blood pressure, SpO2, temperature, weight, and BMI. The whole thing fits in a single &lt;code&gt;index.html&lt;/code&gt; file. No React, no bundler, no npm install. Just a text editor and a browser.&lt;/p&gt;

&lt;p&gt;By the end you'll have a working app and a mental model for how every SMART on FHIR app works under the hood — whether it's a one-page demo or a production clinical decision support tool launching inside Epic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You need two things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A mock.health account.&lt;/strong&gt; Go to &lt;a href="https://mock.health" rel="noopener noreferrer"&gt;mock.health&lt;/a&gt; and sign up. It's free. This gives you a FHIR R4 server with synthetic patients that actually have realistic clinical data — vital signs, conditions, medications, imaging studies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A registered SMART app.&lt;/strong&gt; In the mock.health dashboard, go to &lt;strong&gt;Sandbox → Register App&lt;/strong&gt; and create a new app with these settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App Name:&lt;/strong&gt; Vital Signs Chart (or whatever you want)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect URI:&lt;/strong&gt; &lt;code&gt;http://localhost:8080/smart-on-fhir-vital-signs/index.html&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scopes:&lt;/strong&gt; &lt;code&gt;launch/patient&lt;/code&gt;, &lt;code&gt;patient/Patient.rs&lt;/code&gt;, &lt;code&gt;patient/Observation.rs&lt;/code&gt;, &lt;code&gt;openid&lt;/code&gt;, &lt;code&gt;fhirUser&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copy the &lt;strong&gt;Client ID&lt;/strong&gt; — you'll need it in a minute.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on scope syntax.&lt;/strong&gt; SMART App Launch STU2 introduced a new scope format. The v1 format &lt;code&gt;patient/Patient.read&lt;/code&gt; became &lt;code&gt;patient/Patient.rs&lt;/code&gt; in v2, where the suffix letters map to individual operations: &lt;strong&gt;c&lt;/strong&gt;reate, &lt;strong&gt;r&lt;/strong&gt;ead, &lt;strong&gt;u&lt;/strong&gt;pdate, &lt;strong&gt;d&lt;/strong&gt;elete, &lt;strong&gt;s&lt;/strong&gt;earch. So &lt;code&gt;.rs&lt;/code&gt; means read + search, and v1's &lt;code&gt;.write&lt;/code&gt; maps to v2's &lt;code&gt;.cud&lt;/code&gt;. Servers advertise which format they support via &lt;code&gt;permission-v1&lt;/code&gt; and &lt;code&gt;permission-v2&lt;/code&gt; in their capabilities. Most production EHRs accept both formats today. This tutorial uses v2 (&lt;code&gt;*.rs&lt;/code&gt;), but v1 (&lt;code&gt;*.read&lt;/code&gt;) will also work with mock.health.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The redirect URI matters. It must exactly match the URL where your app is running. When the authorization server sends the user back to your app after login, it appends an authorization code to this URL. If it doesn't match what you registered, the server rejects the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Discover the SMART Endpoints
&lt;/h2&gt;

&lt;p&gt;Every SMART-enabled FHIR server publishes a discovery document at &lt;code&gt;/.well-known/smart-configuration&lt;/code&gt;. This tells your app where to send the user for authorization and where to exchange codes for tokens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.mock.health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/fhir/.well-known/smart-configuration`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// config.authorization_endpoint → where to redirect the user&lt;/span&gt;
&lt;span class="c1"&gt;// config.token_endpoint         → where to exchange the code for a token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.mock.health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.mock.health/smart/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.mock.health/smart/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"launch/patient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"patient/Patient.rs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"patient/Observation.rs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fhirUser"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"capabilities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"launch-standalone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"client-public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"context-standalone-patient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"permission-v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"permission-v2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sso-openid-connect"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why discovery instead of hardcoding? Because every FHIR server puts these endpoints at different paths. Epic uses &lt;code&gt;/oauth2/authorize&lt;/code&gt;. Oracle Health (Cerner) uses &lt;code&gt;/tenants/{tenant}/protocols/oauth2/profiles/smart-v1/personas/patient/authorize&lt;/code&gt;. The discovery document means your app works against any conformant server without code changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Generate a PKCE Challenge
&lt;/h2&gt;

&lt;p&gt;PKCE (Proof Key for Code Exchange, pronounced "pixie") prevents authorization code interception attacks. Your app generates a random secret (the &lt;em&gt;verifier&lt;/em&gt;), hashes it (the &lt;em&gt;challenge&lt;/em&gt;), and sends only the hash to the authorization server. When you exchange the code for a token later, you prove you're the same app by sending the original verifier.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateCodeVerifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;arr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;base64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeCodeChallenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;base64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;base64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the verifier in &lt;code&gt;sessionStorage&lt;/code&gt; — you'll need it after the redirect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generateCodeVerifier&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;challenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;computeCodeChallenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pkce_verifier&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Redirect to Authorize
&lt;/h2&gt;

&lt;p&gt;Build the authorization URL and redirect the user's browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth_state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;response_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;launch/patient patient/Patient.rs patient/Observation.rs openid fhirUser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/fhir`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;code_challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;code_challenge_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;S256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorization_endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each parameter does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;response_type: 'code'&lt;/code&gt;&lt;/strong&gt; — We want an authorization code. Always &lt;code&gt;code&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;client_id&lt;/code&gt;&lt;/strong&gt; — Identifies your app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redirect_uri&lt;/code&gt;&lt;/strong&gt; — Where to send the user back. Must exactly match what you registered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scope&lt;/code&gt;&lt;/strong&gt; — What data you're requesting. &lt;code&gt;launch/patient&lt;/code&gt; asks for a patient context. &lt;code&gt;patient/Observation.rs&lt;/code&gt; asks to read and search Observations scoped to that patient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;state&lt;/code&gt;&lt;/strong&gt; — A random string to prevent CSRF attacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aud&lt;/code&gt;&lt;/strong&gt; — The FHIR endpoint this token should work with. Prevents token replay across servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;code_challenge&lt;/code&gt;&lt;/strong&gt; — The PKCE challenge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After this redirect, the user sees a consent screen. When they approve, the server redirects back to your &lt;code&gt;redirect_uri&lt;/code&gt; with &lt;code&gt;?code=...&amp;amp;state=...&lt;/code&gt; appended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Handle the Callback and Exchange the Code
&lt;/h2&gt;

&lt;p&gt;When your page loads, check if there's a &lt;code&gt;code&lt;/code&gt; parameter in the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth_state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;State mismatch — possible CSRF&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pkce_verifier&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization_code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code_verifier&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokenEndpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token response includes everything you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eyJ0eXAiOiJKV1QiLCJhbGci..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"launch/patient patient/Patient.rs patient/Observation.rs openid fhirUser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"patient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3d4-e5f6-7890-abcd-ef1234567890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eyJhbGciOiJSUzI1NiJ9..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two SMART-specific fields: &lt;strong&gt;&lt;code&gt;patient&lt;/code&gt;&lt;/strong&gt; is the launch context — the ID of the patient the user selected. &lt;strong&gt;&lt;code&gt;id_token&lt;/code&gt;&lt;/strong&gt; contains the authenticated user's identity via the &lt;code&gt;fhirUser&lt;/code&gt; claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Fetch Vital Signs
&lt;/h2&gt;

&lt;p&gt;Now you have a bearer token and a patient ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/fhir+json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;patient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/fhir/Patient/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/fhir/Observation?patient=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;category=vital-signs&amp;amp;_sort=-date&amp;amp;_count=200`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each Observation has a LOINC code that identifies what it measures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vital Sign&lt;/th&gt;
&lt;th&gt;LOINC Code&lt;/th&gt;
&lt;th&gt;Value Location&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Heart Rate&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8867-4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;valueQuantity.value&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blood Pressure&lt;/td&gt;
&lt;td&gt;&lt;code&gt;85354-9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;component[].valueQuantity.value&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temperature&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8310-5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;valueQuantity.value&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SpO2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2708-6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;valueQuantity.value&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weight&lt;/td&gt;
&lt;td&gt;&lt;code&gt;29463-7&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;valueQuantity.value&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BMI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;39156-5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;valueQuantity.value&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Blood pressure is the odd one out. It's a &lt;em&gt;panel&lt;/em&gt; — the Observation itself has LOINC code &lt;code&gt;85354-9&lt;/code&gt;, but the actual systolic and diastolic values live in &lt;code&gt;component&lt;/code&gt; entries with their own LOINC codes (&lt;code&gt;8480-6&lt;/code&gt; for systolic, &lt;code&gt;8462-4&lt;/code&gt; for diastolic). Every FHIR developer hits this for the first time and wonders why it's nested. Welcome to the club.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Render the Charts
&lt;/h2&gt;

&lt;p&gt;We use &lt;a href="https://www.chartjs.org/" rel="noopener noreferrer"&gt;Chart.js&lt;/a&gt; — one CDN script tag, no build step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/chart.js@4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Group observations by type, sort by date, render a line chart for each. For blood pressure, render two datasets on the same chart — systolic and diastolic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete App
&lt;/h2&gt;

&lt;p&gt;The full working app is ~475 lines in a single &lt;code&gt;index.html&lt;/code&gt;. Clone it and run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/mock-health/samples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;samples
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; http.server 8080

&lt;span class="c"&gt;# Open http://localhost:8080/smart-on-fhir-vital-signs/index.html&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enter your Client ID, click &lt;strong&gt;Connect&lt;/strong&gt;, approve access, and you'll see your patient's vital signs charted out.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/mock-health/samples/tree/main/smart-on-fhir-vital-signs" rel="noopener noreferrer"&gt;full source is on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changes in Production EHRs
&lt;/h2&gt;

&lt;p&gt;This tutorial uses mock.health, which implements the SMART spec faithfully. Production EHRs are different:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App registration takes weeks, not seconds.&lt;/strong&gt; Epic's App Orchard requires a formal review process. Budget 2–8 weeks for approval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scopes vary.&lt;/strong&gt; Some EHRs still only accept v1 syntax (&lt;code&gt;patient/Observation.read&lt;/code&gt;). Check the server's &lt;code&gt;.well-known/smart-configuration&lt;/code&gt; capabilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discovery endpoints aren't always where you expect.&lt;/strong&gt; Some older servers don't support &lt;code&gt;.well-known/smart-configuration&lt;/code&gt;. Fall back to the &lt;code&gt;CapabilityStatement&lt;/code&gt; (&lt;code&gt;GET /metadata&lt;/code&gt;) and extract OAuth URLs from &lt;code&gt;rest[0].security.extension&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tokens expire and refresh tokens matter.&lt;/strong&gt; In production, request &lt;code&gt;offline_access&lt;/code&gt; scope and implement refresh token rotation.&lt;/p&gt;

&lt;p&gt;None of this changes the fundamental flow. Discovery → PKCE → authorize → callback → token → API calls. Same everywhere. The operational details differ.&lt;/p&gt;




&lt;p&gt;Sign up at &lt;a href="https://mock.health" rel="noopener noreferrer"&gt;mock.health&lt;/a&gt; — free tier, no sales call. The hardest part of building a SMART on FHIR app is getting access to a server that has realistic data and implements the spec correctly. Now you have one.&lt;/p&gt;

</description>
      <category>fhir</category>
      <category>healthtech</category>
      <category>tutorial</category>
      <category>oauth</category>
    </item>
    <item>
      <title>The FHIR Sandbox Problem: Why Open Epic Won't Get You to Your First Customer</title>
      <dc:creator>mock health</dc:creator>
      <pubDate>Sun, 22 Mar 2026 21:05:56 +0000</pubDate>
      <link>https://dev.to/mockhealth/the-fhir-sandbox-problem-why-open-epic-wont-get-you-to-your-first-customer-4nea</link>
      <guid>https://dev.to/mockhealth/the-fhir-sandbox-problem-why-open-epic-wont-get-you-to-your-first-customer-4nea</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2hzlczkg4cts8pmhk6ms.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2hzlczkg4cts8pmhk6ms.png" alt=" " width="654" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You're three months into building a FHIR integration. You've got OAuth working, you can pull Patient resources, your UI renders demographics cleanly. Time to show it to a potential customer.&lt;/p&gt;

&lt;p&gt;You open the sandbox patient and see this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"erXuFYUfucBZaryVksYEcMg3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"use"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usual"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Test Cancer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"family"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cancer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"given"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Test"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"birthDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1971-08-07"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"female"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"use"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"home"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"line"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"123 Main St."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Madison"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"postalCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"53703"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The patient's name is "Test Cancer." The address is 123 Main St. There are no emergency contacts, no marital status, no communication preferences. You scroll through the rest of the sandbox patients. "Derrick Lin." "Jason Argonaut." A handful of others, all equally sparse.&lt;/p&gt;

&lt;p&gt;This is Open Epic's sandbox. And it's the starting point for nearly every FHIR startup in the US.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Open Epic Actually Gives You
&lt;/h2&gt;

&lt;p&gt;Open Epic provides a shared FHIR R4 sandbox with eight named test patients (Camila Lopez, Derrick Lin, Desiree Powell, Elijah Davis, Linda Ross, Olivia Roberts, Warren McGinnis, and Jason Argonaut). These patients exist to validate API connectivity — to confirm that your OAuth flow works, that you can parse a Bundle, that your FHIR client handles pagination. They were never designed to represent what real patient data looks like.&lt;/p&gt;

&lt;p&gt;Here's what the sandbox gives you versus what you need to demo a real clinical application:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Open Epic Sandbox&lt;/th&gt;
&lt;th&gt;Production Demo Needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Patient demographics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Name, DOB, gender, address&lt;/td&gt;
&lt;td&gt;Race, ethnicity, language, multiple addresses, contacts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Conditions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1-3 active conditions&lt;/td&gt;
&lt;td&gt;5-15 conditions with onset dates, comorbidity patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Medications&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sparse, often missing&lt;/td&gt;
&lt;td&gt;Active + historical, with dosage and frequency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lab observations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Few, no trends&lt;/td&gt;
&lt;td&gt;Longitudinal panels (HbA1c over 3 years, lipid trends)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Imaging studies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;ImagingStudy + DiagnosticReport with narrative text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Clinical notes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None or minimal&lt;/td&gt;
&lt;td&gt;Discharge summaries, progress notes, radiology reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Encounters&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1-3 encounters&lt;/td&gt;
&lt;td&gt;Multi-year history across ambulatory, inpatient, ED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vital signs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sparse&lt;/td&gt;
&lt;td&gt;Trending data (BP, weight, heart rate over time)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The sandbox is optimized for certification. It proves your app can authenticate and read FHIR resources. It says nothing about whether your app can handle a real patient record.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Alternative Landscape Is Shrinking
&lt;/h3&gt;

&lt;p&gt;Open Epic isn't the only sandbox, but the options are thinner than you'd think.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logica Health&lt;/strong&gt; (formerly HSPC) ran a well-regarded public sandbox for years. It was retired on October 31, 2024. If your integration documentation still points there, those links are dead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SMART Health IT&lt;/strong&gt; maintains a sandbox with about 100 Synthea-generated patients. The data is structurally valid but clinically shallow — default Synthea output without enrichment. (If you've used it, you know the feeling: the FHIR parses fine, but the patient has three conditions and no meds.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Individual health systems&lt;/strong&gt; sometimes provide sandbox access, but getting it requires a signed BAA, a security review, and months of procurement. That's not a sandbox. That's a sales cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gap Between Sandbox and Production
&lt;/h2&gt;

&lt;p&gt;Missing fields are the obvious problem. The deeper problem is missing &lt;em&gt;clinical coherence&lt;/em&gt; — real patient data tells a story, and sandbox data doesn't have one.&lt;/p&gt;

&lt;h3&gt;
  
  
  No Comorbidities
&lt;/h3&gt;

&lt;p&gt;In the real world, Type 2 Diabetes doesn't travel alone. It shows up with hypertension, hyperlipidemia, chronic kidney disease, and obesity. A 62-year-old diabetic in a production EHR typically has 8-15 active conditions, most of them clinically related.&lt;/p&gt;

&lt;p&gt;Open Epic's test patients have conditions, but they're isolated. You won't find a patient whose diabetes diagnosis is followed by quarterly HbA1c labs trending from 7.2 to 8.1 over 18 months, with a metformin prescription added at diagnosis and a GLP-1 agonist added when the HbA1c crossed 8.0. That's what your app needs to demo against.&lt;/p&gt;

&lt;h3&gt;
  
  
  No Imaging, No Clinical Narrative
&lt;/h3&gt;

&lt;p&gt;Search for ImagingStudy resources in the Open Epic sandbox. Empty bundles. No DiagnosticReports with radiology narrative. No discharge summaries, no progress notes. Production EHRs have &lt;code&gt;presentedForm.data&lt;/code&gt; fields with base64-encoded report text — the full narrative a radiologist dictated. Sandboxes have "FINDINGS: Normal. IMPRESSION: Normal." or nothing at all.&lt;/p&gt;

&lt;p&gt;If you're building anything that touches imaging, clinical NLP, or document summarization — you have zero test data to work with.&lt;/p&gt;

&lt;h3&gt;
  
  
  No Longitudinal History
&lt;/h3&gt;

&lt;p&gt;Real patients have years of history. A production Patient sits at the center of hundreds of linked resources — encounters spanning a decade, medication changes over time, lab trends that tell a clinical story.&lt;/p&gt;

&lt;p&gt;Sandbox patients have one or two encounters. You can't demonstrate a timeline view, trend analysis, or care gap detection when the patient's entire history fits in a single API response.&lt;/p&gt;

&lt;h3&gt;
  
  
  What You Actually Get: Epic Sandbox vs. Production
&lt;/h3&gt;

&lt;p&gt;To make this concrete, here's a real query against the Epic on FHIR sandbox for Camila Lopez — the "Test Cancer" patient from the opening of this post — filtering for laboratory observations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4/Observation&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
?patient=erXuFYUfucBZaryVksYEcMg3&amp;amp;category=laboratory"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/fhir+json"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.total, [.entry[].resource |
    {code: .code.text, date: .effectiveDateTime,
     value: (.valueQuantity.value | tostring) + " " + .valueQuantity.unit}]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A representative sandbox response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c/Hemoglobin.total in Blood"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2019-06-10T15:38:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7.6 %"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Glucose [Mass/volume] in Blood"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2019-06-10T15:38:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"138 mg/dL"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two observations. Both from the same date. No prior values, no subsequent values. If you're building a diabetes management dashboard, a trend chart, or a care gap detector — this tells you nothing.&lt;/p&gt;

&lt;p&gt;Now compare that to what a production EHR returns — or what &lt;a href="https://mock.health" rel="noopener noreferrer"&gt;mock.health&lt;/a&gt; generates — for a 62-year-old with Type 2 Diabetes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2022-01-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6.8 %"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2022-04-22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7.1 %"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2022-07-30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7.4 %"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2022-10-18"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7.9 %"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-01-12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8.1 %"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-04-05"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7.6 %"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-07-20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7.3 %"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-10-11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7.1 %"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-01-08"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7.0 %"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hemoglobin A1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-04-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6.9 %"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Glucose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2022-01-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"128 mg/dL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Glucose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2022-07-30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"156 mg/dL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Glucose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-01-12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"172 mg/dL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Glucose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-07-20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"145 mg/dL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Glucose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-04-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"132 mg/dL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifteen observations spanning two and a half years. You can see the HbA1c climbing from 6.8 to 8.1 — the point at which a clinician would escalate medication — then trending back down after treatment adjustment. The glucose values correlate. There's a clinical story in this data.&lt;/p&gt;

&lt;p&gt;The sandbox has the right resource types. The data inside them is empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Realistic" Means for a FHIR Integration
&lt;/h2&gt;

&lt;p&gt;"Realistic" has a specific definition. It's in the spec.&lt;/p&gt;

&lt;h3&gt;
  
  
  US Core Profiles
&lt;/h3&gt;

&lt;p&gt;&lt;a href="http://hl7.org/fhir/us/core/" rel="noopener noreferrer"&gt;US Core&lt;/a&gt; is the implementation guide that specifies how FHIR resources should be structured in the US healthcare system. Every EHR certified under ONC's Health IT Certification Program must conform to US Core profiles.&lt;/p&gt;

&lt;p&gt;US Core doesn't just say "include a Patient resource." It specifies which fields are &lt;strong&gt;required&lt;/strong&gt; (must be present), which are &lt;strong&gt;must-support&lt;/strong&gt; (must be handled if present), and which extensions are standard. A US Core Patient requires &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;gender&lt;/code&gt;, and &lt;code&gt;identifier&lt;/code&gt;. It must-supports &lt;code&gt;race&lt;/code&gt;, &lt;code&gt;ethnicity&lt;/code&gt;, &lt;code&gt;birthDate&lt;/code&gt;, and &lt;code&gt;communication&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your test data doesn't include must-support fields, you can't verify that your app handles them correctly. And if your demo doesn't show them, your prospect — who looks at US Core-conformant data all day — will notice.&lt;/p&gt;

&lt;h3&gt;
  
  
  USCDI Data Classes
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://www.healthit.gov/isa/united-states-core-data-interoperability-uscdi" rel="noopener noreferrer"&gt;United States Core Data for Interoperability&lt;/a&gt; (USCDI) defines the minimum data classes that certified health IT must support. USCDI v3, mandatory as of January 2026, includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Patient demographics (including sexual orientation and gender identity)&lt;/li&gt;
&lt;li&gt;Allergies and intolerances&lt;/li&gt;
&lt;li&gt;Medications (active and historical)&lt;/li&gt;
&lt;li&gt;Problems (conditions)&lt;/li&gt;
&lt;li&gt;Procedures&lt;/li&gt;
&lt;li&gt;Laboratory results&lt;/li&gt;
&lt;li&gt;Vital signs&lt;/li&gt;
&lt;li&gt;Clinical notes (discharge summaries, progress notes, consultation notes)&lt;/li&gt;
&lt;li&gt;Diagnostic imaging (reports and studies)&lt;/li&gt;
&lt;li&gt;Health insurance information&lt;/li&gt;
&lt;li&gt;Clinical tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your test data should cover all of these. The sandbox covers maybe four.&lt;/p&gt;

&lt;h3&gt;
  
  
  Correlated Clinical Data
&lt;/h3&gt;

&lt;p&gt;The hardest gap to fill is missing &lt;em&gt;correlations&lt;/em&gt;. In real clinical data, conditions, medications, labs, and encounters are causally connected. A diabetes diagnosis triggers HbA1c monitoring every 3-6 months. An elevated creatinine prompts a nephrology referral. An abnormal chest X-ray leads to a CT follow-up.&lt;/p&gt;

&lt;p&gt;Standard synthetic data generators don't model these relationships. A &lt;a href="https://doi.org/10.1186/s12911-019-0793-0" rel="noopener noreferrer"&gt;2019 validation study&lt;/a&gt; of Synthea in &lt;em&gt;BMC Medical Informatics and Decision Making&lt;/em&gt; found that Synthea-generated populations showed 0% blood pressure control rates compared to 69.7% in real-world data. The resources parse. The population statistics are wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;You can query mock.health's FHIR API directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.mock.health/fhir/Patient?_count&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/fhir+json"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.entry[0].resource | {
    name: .name[0].text,
    birthDate,
    gender,
    race: .extension[] | select(.url | contains("race")) | .extension[0].valueCoding.display,
    ethnicity: .extension[] | select(.url | contains("ethnicity")) | .extension[0].valueCoding.display
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a radiology report with actual clinical narrative:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.mock.health/fhir/DiagnosticReport?category=RAD&amp;amp;_count=1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/fhir+json"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'
    .entry[0].resource.presentedForm[0].data'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;base64 -d&lt;/code&gt; decodes the report text. You'll see findings, impressions, and clinical language — not "Normal. Normal. Normal."&lt;/p&gt;

&lt;p&gt;If you've already built a SMART on FHIR app, you can point it at &lt;code&gt;https://api.mock.health/fhir&lt;/code&gt; and get realistic clinical data through the same OAuth flow you'll use in production.&lt;/p&gt;

&lt;p&gt;Sign up at &lt;a href="https://mock.health" rel="noopener noreferrer"&gt;mock.health&lt;/a&gt; — free tier, no sales call.&lt;/p&gt;

</description>
      <category>healthcare</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
