Skip to main content

Vulcan Reaches Epic: Our First Live FHIR Handshake

· 8 min read
Creator, Parthenon

When we introduced Vulcan, our FHIR ingestion engine, it could do something impressive and something frustrating at the same time. It implemented the full SMART Backend Services client flow — sign a JWT, exchange it for a token, kick off a Bulk Data $export, poll for completion, download NDJSON, map it into OMOP CDM. Every step worked. Every test passed.

Against a mock server.

A mock server is a generous conversation partner. You hand it a signed assertion and it hands back a token without looking too closely. A real electronic health record system — Epic, in particular — is not so generous. Before Epic issues you a token, it wants to verify the signature on your assertion. And to do that, it needs something Vulcan had never been built to provide: a way to find our public key.

This is the story of building the missing half of that trust relationship, and of the moment Vulcan stopped talking to mocks and completed its first live handshake with the Epic on FHIR sandbox.

The asymmetry nobody warns you about

SMART Backend Services — the OAuth 2.0 profile that lets a server pull bulk data from an EHR without a human clicking "allow" — is described as a symmetric handshake. In practice the two halves are wildly different in difficulty.

The client half is the part everyone implements first, because it's the part you can test alone:

  1. Build a JWT whose claims say "I am client X, here is a unique token id, this assertion expires in five minutes."
  2. Sign it with your private RSA key (RS384).
  3. POST it to the token endpoint as a client_credentials grant.
  4. Receive a bearer token.

The server half is invisible until you point the client at something real:

Before Epic returns a token, it must verify your signature. To verify your signature, it needs your public key. To find your public key, it fetches a JWKS — a JSON Web Key Set — from a URL you registered. And to know which key in that set signed this assertion, it reads a kid (key id) from the JWT header.

Vulcan had no JWKS endpoint and put no kid in its assertions. Against a mock, that's fine. Against Epic, the token endpoint takes one look at an assertion it cannot verify and replies invalid_client. The pipeline never gets past step one. You can have a flawless NDJSON parser and a beautiful OMOP mapper, and none of it ever runs, because the front door never opens.

The keystone: a public JWKS endpoint and a stable kid

The fix is small in code and large in consequence. Two pieces.

First, a public key-discovery endpoint. GET /api/fhir/jwks.json serves a standard JSON Web Key Set built from the RSA public components — the modulus n and exponent e — of every active connection's signing key. It sits outside authentication, deliberately, right alongside /health. It has to: the EHR needs to reach it before any token exists. And it is safe to expose precisely because it contains only public material — the private exponent is never serialized. (We have a test that asserts the private component never appears in the response. Belt and suspenders.)

Second, a kid the EHR can rely on. Rather than invent an identifier, we compute the RFC 7638 JWK thumbprint — a SHA-256 hash over the canonical key JSON — and use that as the kid. The virtue of a thumbprint is that it is deterministic: the same key always produces the same kid. Key rotation becomes auditable, re-registration becomes idempotent, and a connection that shares a key with another connection collapses to a single published entry. The assertion's header now carries that kid — and, importantly, omits it entirely when no key is present, because a null kid is worse than no kid to a strict verifier.

With those two pieces in place, the full flow finally closes:

Parthenon signs an RS384 assertion, stamps it with the key's kid,
and POSTs it to Epic's token endpoint.


Epic fetches https://parthenon.acumenus.net/api/fhir/jwks.json,
finds the key whose kid matches the header, verifies the signature.


Epic issues an access token. Vulcan begins the Bulk Data $export.

On 2026-06-22, that is exactly what happened — once Epic re-fetched our (now clean) key and propagated it across its token nodes, the kid-bearing assertion was accepted and an access token came back. Then we kept going. Against Camila Lopez, one of Epic's synthetic sandbox patients, Vulcan read real Epic resources over the authenticated channel and ran every one of the six new resource types through its mappers into OMOP CDM: a DocumentReference into a clinical note, a completed ServiceRequest into a procedure_occurrence whose LOINC code resolved live to an OMOP concept, a CarePlan, a Goal carrying its real goal text, and a CareTeam with its "Family Medicine" role. All of it on real Epic data — and all of it inside a rolled-back transaction, so not a single row persisted. A dress rehearsal on a live stage. It was the first time Parthenon authenticated to, read from, and correctly mapped a real, externally-operated EHR FHIR server instead of a stand-in.

One honest asterisk: Epic's public sandbox does not hand self-service apps the bulk $export firehose — that transport is Group-scoped and reserved for vendor-sandbox and production registrations — so we proved the identical pipeline through authenticated reads rather than a bulk NDJSON download. The signature, the token, the reads, and the mapping are all the real thing; only the transport differs, and the transform behind it is the same either way.

What came through the door: six new resource types

Opening the connection was only worth doing if there was something worth ingesting, and here we leaned on work proven in our sister application, Medgnosis, whose FHIR ingestion had already expanded well past the "core" resources. Porting that coverage to Parthenon — retargeted from Medgnosis's warehouse schema to OMOP CDM v5.4 — meant teaching Vulcan six new vocabularies of clinical meaning:

  • DocumentReference → OMOP note. Clinical documents, with their base64 payloads decoded inline.
  • Coveragepayer_plan_period. Who's paying, and for what window.
  • ServiceRequestprocedure_occurrence, but only for real orders — drafts and proposals are skipped, not mis-recorded as performed procedures.
  • CarePlan, Goal, CareTeam → three new OMOP extension tables, because the standard model has no native home for care-coordination. These follow the same bridge pattern we use for imaging and genomics.

CareTeam was the interesting one. A care team is a parent record plus a roster of members that must all point back to it — before anything is written. Rather than insert the team, read back its generated id, and then insert members (a round-trip that breaks batched writes), we allocate a deterministic surrogate key up front from a crosswalk, exactly as we already do for providers and care sites. The team and every member share that id in a single pass, and a re-sync produces the same id every time.

To make adding all of this sane, we first refactored the mapper into a pluggable registry: each resource type is now its own small class implementing a ResourceMapper interface, registered at boot. Adding a resource type went from "perform surgery on an 855-line monolith" to "write a class and add one line." That seam is the quiet hero of this release.

Data gets corrected, too

Real clinical data isn't append-only. A diagnosis entered on the wrong chart gets retracted; a result gets superseded. FHIR expresses this two ways, and Vulcan now honors both: a resource marked entered-in-error during a sync deletes the CDM row it previously produced, and the Bulk Data deleted manifest — the list of resources removed since the last export — is parsed and applied. Every deletion is stamped with a timestamp and a reason in the crosswalk, so the audit trail survives even when the clinical row does not. We were honest in the code about the one case we can't yet pinpoint (batch-inserted rows whose individual ids weren't captured) — those are audited and flagged rather than silently dropped, and tightening that is on the list.

Why this matters

OMOP is the destination, but EHRs are where the data lives, and the most painful, most expensive part of building an OMOP repository has always been the extract. The mature implementations — Mt. Sinai, Johns Hopkins — got there by writing thousands of hours of bespoke ETL against proprietary database schemas. FHIR Bulk Data is the bet that you can do it vendor-agnostically, against a standard API, in a fraction of the time.

That bet only pays off if the connection actually works against the systems clinicians actually use. As of this week, against Epic's sandbox, it does — Vulcan has shaken hands with Epic, read its data, and mapped that data correctly into OMOP. The path from a production EHR to a research-ready OMOP CDM is one large, real step shorter.

Next, we earn a persisted run: a Group-scoped bulk export from Epic's vendor sandbox or a real site (pointed at a dedicated external-EHR schema, not the curated CDM), the deferred concept mappings so care-coordination data lands as coded concepts rather than source text, and Epic's production registration. But the hardest parts — getting a real EHR to trust us enough to open the door, and proving our mappers handle what it actually sends — are done.

Vulcan kept the forge. This week, it forged a key that Epic accepted.