Bulk FHIR is the Bulk Data Access standard for pulling population-level data out of an EHR: you call $export, the server builds the dataset in the background, and you download one NDJSON file per resource type. Epic, Oracle Health and athenahealth all support Group exports, but they differ sharply on _since, filters, Group creation, file retention and limits.
This guide is for teams feeding analytics, AI training data, risk or quality programs from EHR data. It walks the request sequence with curl, then covers the per-vendor behavior the Implementation Guide leaves out. For the orchestration built inside an interface engine, see our Bulk Data pipeline built on Mirth Connect.
Key takeaways
- The current Bulk Data Access IG is v3.0.0 (STU 3, December 2025), but ONC certification only requires Group export, so EHR support centers on
Group/[id]/$export. - Epic: Group export only,
_typeand_typeFilter, no_since, one run per group per client per 24 hours by default, results deleted after 14 days. - Oracle Health: Group export plus a POST Patient export for up to 20,000 IDs, with
_sinceand_typeFilter; files kept 30 days. - athenahealth: one practice-wide Group (
a-1.c-[practiceId]) with_type,_sinceand_outputFormat. - On Epic and Oracle Health the health system builds the Group. Ask for it in week one.
- Bulk is a batch tool. For freshness in minutes, pair it with HL7v2 ADT feeds, FHIR Subscriptions or per-patient queries.
How does Bulk FHIR export work?
A Bulk FHIR export is an asynchronous conversation. Your backend service gets a short-lived token with a signed JWT, kicks off $export, polls a status URL until the server returns a manifest, downloads each NDJSON file, then deletes the job. Nothing returns data synchronously, and the server decides how long the job takes.
- Register a backend client with the EHR's authorization server and share your public keys, ideally as a JWKS URL.
- Get an access token by signing a JWT (RS384 or ES384) and posting it with
grant_type=client_credentials. - Kick off
GET [base]/Group/[id]/$exportwithAccept: application/fhir+jsonandPrefer: respond-async. The server returns202 Acceptedand aContent-Locationstatus URL. - Poll the status URL. In-progress responses are
202, optionally withX-ProgressandRetry-After. Back off exponentially. - Read the manifest. Completion is
200 OKwithtransactionTime,requiresAccessToken, anoutputarray of files per resource type, anerrorarray of OperationOutcome files and, optionally,deleted. - Download the files, with a bearer token when
requiresAccessTokenis true. Each line is one complete resource. - Delete the job with
DELETEon the status URL. Later polls return404.
The Bulk Data export specification defines three kick-off endpoints: [base]/$export for the whole server, [base]/Patient/$export for all patients, and [base]/Group/[id]/$export for one Group's members. Servers SHALL support GET kick-off and MAY support POST. The two headers are SHOULD for clients and a server MAY reject requests without them, so always send both.
Why does every certified EHR support Group export?
Because certification requires it. The 45 CFR 170.315(g)(10) Standardized API criterion requires certified health IT to answer requests for multiple patients' data as a group using the Bulk Data standard at 170.215(d), with system-scope authorization through SMART Backend Services.
170.215(d)(1) adopts Bulk Data Access v1.0.0 with mandatory support for the group-export OperationDefinition. That is the floor. System and Patient export, _since, _typeFilter and newer parameters are optional, which is why vendors look so different. The current IG is v3.0.0, trial use, on FHIR R4 4.0.1.
Bulk FHIR kick-off parameters in the v3.0.0 IG
| Parameter | What it does | Practical note |
|---|---|---|
_outputFormat | Output format; defaults to application/fhir+ndjson | Leave unset |
_type | Comma-delimited resource types | Always set it; Epic and Oracle Health both recommend it |
_since | Resources changed after an instant, for example by meta.lastUpdated | Incremental loads; Group exports MAY add older data for newly added members |
_until | Resources changed before an instant | Not listed by the three vendors below |
_typeFilter | A FHIR search query per resource type | Use it for clinical date windows, not _since |
_elements | Return only listed elements | Not listed by the three vendors |
patient | Limit to specific patients (POST) | Oracle Health Patient export takes up to 20,000 |
includeAssociatedData | Add associated data such as Provenance | Epic accepts LatestProvenanceResources |
organizeOutputBy | Per-resource blocks (for example per Patient) instead of per-type files | Check the CapabilityStatement first |
allowPartialManifests | Manifest of finished files before the job completes | Useful for very large jobs where supported |
A server that cannot honor a parameter SHOULD return an OperationOutcome error. With Prefer: handling=lenient it MAY ignore unsupported parameters instead; Oracle Health documents exactly that, returning 422 without the header. Before designing around optional parameters, read the server's CapabilityStatement.
How does SMART Backend Services authentication work for Bulk FHIR?
Bulk FHIR uses SMART Backend Services, an OAuth 2.0 client credentials grant where your service proves its identity with a JWT signed by a private key the EHR already trusts. There is no user or browser. You receive a short-lived bearer token with system scopes and send it on kick-off, status and file requests.
- Keys: register a JWKS URL or upload a JWKS; servers SHALL support both. A JWKS URL makes rotation a publishing task.
- Algorithms: clients SHALL support RS384 and ES384; servers advertise theirs at
/.well-known/smart-configuration. - Header:
alg,kid,typ: JWT, and an optionaljkuthat must match your registered JWKS URL. - Claims:
issandsubequal your client_id,audis the token URL,expat most five minutes ahead,jtiunique. - Lifetime: the SMART Backend Services profile says
expires_inSHOULD NOT exceed 300 seconds, so exports outlive many tokens. - Scopes:
system/scopes such assystem/Observation.rs(SMART v2) orsystem/Observation.read(v1).
The signing rules come from the asymmetric client authentication profile, and vendors add their own. Epic says its backend OAuth differs from the SMART profile in some respects; its troubleshooting guide requires the client ID in both iss and sub and a jti of 151 characters or fewer, and from the May 2026 version backend apps are expected to use a JWK Set URL. Oracle Health requires a Millennium Bulk Data SMART app registered in code Console, a JWKS in Cerner Central System Account Management, and explicitly named scopes, because its authorization framework rejects wildcards like system/*.read. athenahealth's smart-configuration advertises private_key_jwt. For user-facing flows, see our SMART on FHIR OAuth 2.0 guide.
Bulk FHIR request walkthrough with curl
This sequence uses Epic's public sandbox base URL and the sandbox Group ID from Epic's Bulk Data tutorial. The client ID, key ID and JWKS host are placeholders. For Oracle Health or athenahealth, swap the base URL, token URL and Group ID; the calls keep the same shape.
Step 1: Sign the client assertion
import time, uuid
import jwt # PyJWT with the cryptography package
CLIENT_ID = "your-non-production-client-id"
TOKEN_URL = "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token"
now = int(time.time())
assertion = jwt.encode(
{"iss": CLIENT_ID, "sub": CLIENT_ID, "aud": TOKEN_URL,
"jti": str(uuid.uuid4()), "iat": now, "nbf": now,
"exp": now + 240}, # under the five-minute ceiling
open("bulk-client-private.pem").read(),
algorithm="RS384",
headers={"kid": "bulk-2026-09", "typ": "JWT",
"jku": "https://keys.example-health-app.com/.well-known/jwks.json"},
)
print(assertion) Step 2: Exchange it for an access token
curl -s -X POST "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "client_assertion=$(python3 make_assertion.py)" The response carries access_token, token_type, expires_in and scope. SMART-conformant servers expect a scope parameter and Oracle Health requires each scope named. On Epic, what you can export is governed by the APIs authorized for your client, including the Bulk Data APIs and the R4 Search API for each resource type.
Step 3: Kick off the Group export
BASE="https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4"
GROUP="eIscQb2HmqkT.aPxBKDR1mIj3721CpVk1suC7rlu3yX83"
curl -s -D - -o /dev/null \
"$BASE/Group/$GROUP/\$export?_type=Patient,Condition,Observation" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/fhir+json" \
-H "Prefer: respond-async"
# HTTP/1.1 202 Accepted
# Content-Location: https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/BulkRequest/B0F84FB8D37411EB92726C04221B350C Step 4: Poll with backoff
STATUS_URL="https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/BulkRequest/B0F84FB8D37411EB92726C04221B350C"
DELAY=600 # Epic suggests 10 minutes for groups of 100 or fewer
while true; do
CODE=$(curl -s -D headers.txt -o manifest.json -w "%{http_code}" "$STATUS_URL" \
-H "Authorization: Bearer $(./fresh-token.sh)" -H "Accept: application/json")
[ "$CODE" = "200" ] && break
[ "$CODE" != "202" ] && [ "$CODE" != "429" ] && { cat manifest.json; exit 1; }
RA=$(grep -i "^retry-after" headers.txt | tr -dc '0-9') # may also be an HTTP date
sleep "${RA:-$DELAY}"
done The completed manifest, from Epic's documented sample and trimmed:
{
"transactionTime": "2021-06-23T16:39:52Z",
"request": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4/Group/eIscQb2HmqkT.aPxBKDR1mIj3721CpVk1suC7rlu3yX83/$export?_type=patient,encounter,condition",
"requiresAccessToken": true,
"output": [
{"type": "Patient", "url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/BulkRequest/9ED3042CD44111EB84F2D2068206269D/e19upATM-PTKGZuHsy04IUQ3"}
],
"error": [
{"type": "OperationOutcome", "url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/BulkRequest/9ED3042CD44111EB84F2D2068206269D/eKbGgVw9pUn6xIiB8kZ756w3"}
]
} Parse requiresAccessToken defensively: Epic changed it from a string to a Boolean in its February 2026 version, and customers upgrade on different timelines.
Step 5: Download every file, then delete the job
RUN_ID=$(date -u +%Y%m%dT%H%M%SZ); mkdir -p "bronze/run=$RUN_ID"
cp manifest.json "bronze/run=$RUN_ID/"
jq -r '.output[], .error[]? | "\(.type) \(.url)"' manifest.json |
while read -r TYPE URL; do
curl -s --fail --retry 3 "$URL" -H "Authorization: Bearer $(./fresh-token.sh)" \
-H "Accept: application/fhir+ndjson" -o "bronze/run=$RUN_ID/$TYPE-$(basename "$URL").ndjson"
done
curl -s -X DELETE "$STATUS_URL" -H "Authorization: Bearer $(./fresh-token.sh)" On Epic, the same client and user that kicked off the job must make the status and file requests. If file links expire, the spec lets you re-fetch the manifest for refreshed links.
Bulk FHIR on Epic vs Oracle Health vs athenahealth
All three expose Group-level $export behind backend authorization, and that is where the similarity ends. Epic has no _since and allows one run per group per day by default. Oracle Health adds _since, _typeFilter and a Patient export by ID list. athenahealth exposes one practice-wide Group with _type and _since.
| Capability | Epic | Oracle Health (Millennium) | athenahealth |
|---|---|---|---|
| Kick-off levels | Group only | Group (GET); Patient export with ID list (POST) | Group only |
| IG version stated | 1.0.1 plus some 1.1.0 features | Group 1.0.1 plus experimental 2.0.0 parameters; Patient 2.0.0 | Profile of group-export 1.0.1 |
_type | Yes; only way to get Binary | Yes | Yes |
_typeFilter | Yes, from November 2023 version; not available to Payer-to-Payer API clients (kick-off spec) | Yes | Not listed |
_since | Not supported | Yes | Yes |
| Who defines the Group | Health system registry or roster, authorized per client | Health system, in Ignite Management Tooling | Practice: a-1.c-[practiceId] |
| Size guidance | Registry guidance: around 1,000 patients or fewer | Up to 20,000; 10,000 or fewer recommended | None found in accessible public docs |
| Frequency limit | One request per group per client per 24 hours by default, configurable | Not published; 429 when throttled | None found |
| File retention | 14 days from kick-off | 30 days | None found |
| File size | At most 3,000 resource instances per file | Not published | Not published |
Epic
Epic's FHIR Bulk Data Access tutorial is candid: exports run on the organization's operational database, are not incremental, and return nothing until the whole job is ready. The default set covers USCDI data classes, the patient compartment and supporting resources, including Provenance. Binary is excluded unless named in _type, and some very large Binary files cannot be exported, so read those from DocumentReference.content.attachment.url. _typeFilter needs the resource in _type and cannot filter by patient, subject or _id. Errors are request-level only; handle 59130 (unauthorized resource), 59100 (unsupported parameter), 59136 (unsupported resource) and 59176 (resource too large, read it separately). Epic suggests polling every ten minutes up to 100 patients and every thirty above that.
Oracle Health
Oracle's Millennium Bulk Data Access docs use separate job (/bulk-export/jobs/{Job_ID}) and file (/bulk-export/files/{File_ID}) endpoints, surfaced through the standard manifest. The export reference says Group export returns USCDI v1 resources and Patient export takes a patient ID list as an alternative to creating a group. It covers 17 clinical resource types (AllergyIntolerance through ServiceRequest) and returns Location, Organization, Practitioner, Provenance, RelatedPerson and Specimen only when at least one clinical type is also requested; a non-JSON Content-Type on that POST gets 415. Open endpoints do not support bulk export, so testing runs against the secured sandbox. The sandbox publishes ready Groups 11ec-d16a-c763b73e-98e8-a31715e6a2bf (3 patients) and 11ec-d16a-b40370f8-9d31-577f11a339c5 (10), the easiest place to test a poller end to end.
athenahealth
athenahealth's Group profile says the API only supports Group-level export with the ID formatted as a-1.c-[practiceId], and its group export OperationDefinition limits parameters to _outputFormat, _type and _since. Your cohort is the whole practice, so narrow by type and time at the source and apply clinical criteria downstream. More in our athenahealth API developer guide.
For the wider API picture, see our EHR integration API comparison, and if you are weighing an aggregator, Particle Health vs Health Gorilla vs Redox vs direct integration.
Planning a population export across several EHRs? We build and run these integrations for product teams. Talk to our team and we will map what each of your target EHRs actually exposes for Bulk FHIR, including Group provisioning and refresh limits.
How do you get a Group ID for a Bulk FHIR export?
Usually you ask for one. On Epic and Oracle Health the health system builds the Group, authorizes your client and gives you its FHIR ID. The IG describes client-managed groups, but we did not find that documented for external apps in these three vendors' public docs, so plan Group provisioning as a project task, not an API call.
The IG's Groups page names three patterns: read-only groups managed by the server (such as an EHR registry list), member-based groups the client edits, and criteria-based groups the client defines by characteristics. The optional Bulk Cohort API adds asynchronous Group creation with member-filter expressions and scopes like system/Group.cud, but it is experimental at maturity level 1.
- Epic: the health system defines a roster through logical rules, typically an analytics registry around your inclusion criteria. Epic's Specialty Registries Playbook suggests cohorts around 1,000 patients or fewer and says Bulk is not meant for very large or high-frequency extractions.
- Oracle Health: the organization manages groups in Ignite Management Tooling. If you already know the patients, the POST Patient export with up to 20,000 IDs avoids waiting for a new Group.
- athenahealth: the Group is the practice, so nothing to build and no clinical cohort at the source.
A Group request that gets built quickly states inclusion criteria in clinical terms, membership refresh cadence, expected size, resource types and date windows, export frequency, and which environment comes first. On Epic, ask for the request window you need at the same time.
How do incremental Bulk FHIR exports work with _since?
Store the transactionTime from each completed manifest and pass it as _since on the next kick-off. The server returns only resources that changed after that instant. Use the server's time, never your clock, and expect overlap, re-sent records and, for Group exports, older data for patients added since the last run.
When _since is supplied, the manifest's deleted array SHOULD point to NDJSON files of transaction Bundles whose entries use request.method DELETE; apply them as tombstones. For clinical date ranges use _typeFilter, because _since tracks when a record changed, not when care happened.
Dedupe on source system, tenant, resource type and id. Keep the latest meta.lastUpdated, break ties with meta.versionId, and fall back to a content hash when neither exists.
Epic has no _since, and its tutorial lists warehouse synchronization and incremental loads as poor fits. If you still need periodic refresh there, keep the Group small, narrow runs with _typeFilter date windows, hash resources and diff snapshots. A resource missing from a re-export is a deletion candidate, not a proven delete, so flag it. For ongoing change capture, use events.
How do you land Bulk FHIR NDJSON in a lakehouse?
Treat each export run as an immutable drop. Land raw NDJSON and the manifest in bronze keyed by run, convert to typed Parquet tables per resource type in silver with dedupe, and build cohorts, measures and ML features in gold. Keep the raw JSON, because vendor extensions will change under you.
| Layer | What lands | Design choice that matters |
|---|---|---|
| Bronze | NDJSON, manifest, request URL, transactionTime | Partition by source, tenant and run; never mutate. This is your replay log and history. |
| Silver | One Delta, Iceberg or Parquet table per resource type | Flatten profiled US Core elements; keep the full resource as a JSON column; upsert on the dedupe key. |
| Gold | Cohorts, quality measures, risk features, AI training sets | Join on normalized references; de-identify before data leaves the clinical boundary. |
| Quarantine | Error-file OperationOutcomes, unparseable lines | Alert and reconcile counts per run. |
Schema drift. The same resource differs by vendor and site: extensions, multiple codings, and choice elements like Observation.value[x]. Give each value[x] variant its own column, promote only profiled elements, and keep the rest in JSON so a new extension never breaks a load. Normalize relative and absolute references before joining.
Versioning. An export is current state, not history. If risk logic needs last quarter's record, build slowly changing dimensions from meta.lastUpdated across bronze runs.
Orchestration. Kick-off, poll, download, delete and watermark are a small state machine. We run Mirth Connect engines in production and often use them for this; Airflow works too. See our healthcare data lakehouse guide, the FHIR AI and ML data pipeline guide for training data, and our FHIR data store comparison if a managed FHIR store fits better.
When is Bulk FHIR the wrong tool?
When you need data within minutes, one patient right now, or a continuously synced warehouse. Epic's own tutorial calls warehouse synchronization and incremental loads poor fits. Use Bulk for backfills and scheduled cohort refreshes, and move ongoing change capture to HL7v2 ADT feeds, FHIR Subscriptions or per-patient FHIR queries.
| What you need | Best fit | Why |
|---|---|---|
| Cohort history before go-live | Bulk FHIR Group export | One async job instead of thousands of paged searches |
| Weekly or monthly quality, risk or registry refresh | Bulk FHIR with _type, _typeFilter, _since where supported | Matches batch cadence and vendors' stated good uses |
| Admits, discharges, transfers within minutes | HL7v2 ADT feed | Event-driven; the feed most hospitals already run |
| React to specific resource changes | FHIR Subscriptions where supported | Push instead of polling; support varies by vendor |
| One patient during a workflow | Per-patient US Core REST queries | Synchronous and small |
Production usually needs both: a Bulk backfill, then events. See real-time ADT event processing, event-driven pipelines with FHIR Subscriptions, and our FHIR Bundle resource guide for paged per-patient results.
Where teams get stuck with Bulk FHIR
Rarely on the HTTP calls. Calendar time goes to Group provisioning, exports that outlive tokens, partial failures that look like success, key rotation and huge Observation files. Each is predictable, and each has a cheap fix if you design for it before the first production run.
- Group lead time. Health system analysts build the Group behind their own queue, often after your EHR integration code is done, leaving a finished pipeline that moves no data. Fix: send the Group definition with your first technical request.
- Long-running exports. Tokens are short-lived and jobs are not. A poller that crashes and re-kicks can cost a full day on Epic, where the default is one request per group per client per 24 hours (deleting an unfinished request does not count). Fix: persist the status URL and mint a token per call.
- Partial failures. The spec returns
200with a populatederrorarray when some resources fail, so green is not complete. Fix: always download error files, reconcile counts per run, queue individual reads for Epic 59176 entries. - Retention windows. Epic deletes results 14 days after kick-off; Oracle Health expires files after 30 days. Fix: download into bronze immediately, then delete the job.
- Key rotation. A static key means every customer reconfigures when it changes. Fix: publish a JWKS URL, add the new
kidbefore signing with it, and retire the old key after caches expire; servers SHALL NOT cache a JWKS beyond your Cache-Control header. - Huge Observation files. Vitals and labs dominate, and Epic caps files at 3,000 resource instances, so expect many files. Fix: filter category and date with
_typeFilter, stream line by line, write Parquet in batches. - Silent format changes. Epic's
requiresAccessTokentype change breaks strict parsers. Fix: tolerant parsing and a contract test per customer environment.
Bulk FHIR production checklist
- Confirm Bulk operations and parameters in each server's CapabilityStatement and vendor docs.
- Register a backend client per environment with a JWKS URL.
- Send the Group definition, refresh cadence, size and export frequency to the health system early.
- On Epic, agree the request window if one run per group per day is not enough.
- Always send
AcceptandPrefer: respond-async, and always set_type. - Use
_typeFilterfor clinical windows and_sinceonly for change tracking. - Persist the status URL, honor
Retry-Afterand429, and back off exponentially. - Mint short-lived tokens per call so expiry never kills a job.
- Download output and error files immediately, verify counts, then
DELETE. - Store manifest
transactionTimeas the next watermark. - Land NDJSON immutably, dedupe in silver, and apply
deletedtombstones. - Alert on error volume, empty resource types, retention deadlines and key expiry.
Need Bulk FHIR running across more than one EHR? Our Healthcare Interoperability Solutions team builds EHR integrations across Epic, Oracle Health, athenahealth, eClinicalWorks, NextGen and others, and our Healthcare Software Product Development engineers design the pipelines and lakehouse behind them. Talk to our team to scope your population data integration.



