Nirmitee.io
FHIRBulk DataEHR Integration

Bulk FHIR Export in 2026: How Epic, Oracle Health and athenahealth Handle $export, Groups and Limits

September 8, 202619 min readUpdated Sep 25, 2026
Written by
Jitendra Choudhary
Jitendra Choudhary

CTO & Co-Founder

CTO & Co-Founder at Nirmitee.io. Architects healthcare integrations across FHIR, SMART on FHIR, ABDM and NHCX, writing from production experience taking hospital software from sandbox to go-live.

Bulk FHIR Export in 2026: How Epic, Oracle Health and athenahealth Handle $export, Groups and Limits

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, _type and _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 _since and _typeFilter; files kept 30 days.
  • athenahealth: one practice-wide Group (a-1.c-[practiceId]) with _type, _since and _outputFormat, no Bulk in the public sandbox, and files kept for up to 30 days.
  • eClinicalWorks: eClinicalWorks Group export follows Bulk Data 1.0.1 on groups the practice saves from its Registry, with _type, _since and _outputFormat; an identical request is rejected while one is still in progress.
  • Practice Fusion: all-patient Patient/$export or Group/[id]/$export on each practice's own base URL, with Groups created by practice admins in the EHR; see our Practice Fusion bulk export guide.
  • 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.

  1. Register a backend client with the EHR's authorization server and share your public keys, ideally as a JWKS URL.
  2. Get an access token by signing a JWT (RS384 or ES384) and posting it with grant_type=client_credentials.
  3. Kick off GET [base]/Group/[id]/$export with Accept: application/fhir+json and Prefer: respond-async. The server returns 202 Accepted and a Content-Location status URL.
  4. Poll the status URL. In-progress responses are 202, optionally with X-Progress and Retry-After. Back off exponentially.
  5. Read the manifest. Completion is 200 OK with transactionTime, requiresAccessToken, an output array of files per resource type, an error array of OperationOutcome files and, optionally, deleted.
  6. Download the files, with a bearer token when requiresAccessToken is true. Each line is one complete resource.
  7. Delete the job with DELETE on the status URL. Later polls return 404.

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

ParameterWhat it doesPractical note
_outputFormatOutput format; defaults to application/fhir+ndjsonLeave unset
_typeComma-delimited resource typesAlways set it; Epic and Oracle Health both recommend it
_sinceResources changed after an instant, for example by meta.lastUpdatedIncremental loads; Group exports MAY add older data for newly added members
_untilResources changed before an instantNot listed by the three vendors below
_typeFilterA FHIR search query per resource typeUse it for clinical date windows, not _since
_elementsReturn only listed elementsNot listed by the three vendors
patientLimit to specific patients (POST)Oracle Health Patient export takes up to 20,000
includeAssociatedDataAdd associated data such as ProvenanceEpic accepts LatestProvenanceResources
organizeOutputByPer-resource blocks (for example per Patient) instead of per-type filesCheck the CapabilityStatement first
allowPartialManifestsManifest of finished files before the job completesUseful 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 optional jku that must match your registered JWKS URL.
  • Claims: iss and sub equal your client_id, aud is the token URL, exp at most five minutes ahead, jti unique.
  • Lifetime: the SMART Backend Services profile says expires_in SHOULD NOT exceed 300 seconds, so exports outlive many tokens.
  • Scopes: system/ scopes such as system/Observation.rs (SMART v2) or system/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, except that athenahealth returns the polling URL in Location.

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, and positions it for initial loads and monthly syncs rather than daily ones. Our athena FHIR API walkthrough covers the 2-legged token and practice enablement that an athenahealth export depends on.

CapabilityEpicOracle Health (Millennium)athenahealth
Kick-off levelsGroup onlyGroup (GET); Patient export with ID list (POST)Group only
IG version stated1.0.1 plus some 1.1.0 featuresGroup 1.0.1 plus experimental 2.0.0 parameters; Patient 2.0.0Profile of group-export 1.0.1
_typeYes; only way to get BinaryYesYes
_typeFilterYes, from November 2023 version; not available to Payer-to-Payer API clients (kick-off spec)YesNot listed
_sinceNot supportedYesYes
Who defines the GroupHealth system registry or roster, authorized per clientHealth system, in Ignite Management ToolingPractice: a-1.c-[practiceId]
Size guidanceRegistry guidance: around 1,000 patients or fewerUp to 20,000; 10,000 or fewer recommendedWhole practice, no cohort sizing; not recommended for daily syncs
Frequency limitOne request per group per client per 24 hours by default, configurableNot published; 429 when throttledConcurrency caps: production 5 jobs per practice, 10 per client, 2 per client per practice (preview 2, 5, 1); 429 when exceeded
File retention14 days from kick-off30 daysLesser of 30 days after completion or 60 days after kick-off; download URLs expire 60 minutes after manifest retrieval
File sizeAt most 3,000 resource instances per fileNot publishedNo defined upper limit; files can be multiple GB

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.

athenahealth's FHIR Bulk Export Workflow fills in the operational rules. The workflow page writes the Group ID as a-1.C-{Practice} while the Group reference uses a-1.c-[practiceId], so confirm the form your Preview practice accepts. The kick-off must send Prefer: respond-async and Accept: application/fhir+json (406 otherwise), and the polling URL comes back in the Location header rather than Content-Location. Poll it with the same client ID: a 202 carries an X-Progress percentage and Retry-After, and a 200 returns a manifest of pre-signed S3 URLs that expire 60 minutes after you retrieve it, so poll again to refresh them. Exported data stays available for the lesser of 30 days after completion or 60 days after the request, a DELETE on the polling URL cancels the job or cleans up its files, and partial failures arrive as an error file of OperationOutcomes that name the affected patients.

Scope and limits matter as much. Bulk export is not available in the public sandbox (practices 195900 and 1128700), so testing needs a customer's Preview practice or your own. The token needs system/[Resource].read scopes (or SMART v2 .rs) for up to 24 resource types, and the export returns only the types your scopes cover; Location, Medication, Organization, Practitioner and Provenance appear only when referenced, and Binary and Media are not exported. athenahealth recommends Bulk for initial loads, monthly syncs with _since and population health transfers, not for daily synchronization or real-time needs, where it points to Data View. Jobs can slow or pause under adaptive rate limiting that favors athenaOne users, and going past the concurrency caps returns 429 with "Too many active exports".

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.

LayerWhat landsDesign choice that matters
BronzeNDJSON, manifest, request URL, transactionTimePartition by source, tenant and run; never mutate. This is your replay log and history.
SilverOne Delta, Iceberg or Parquet table per resource typeFlatten profiled US Core elements; keep the full resource as a JSON column; upsert on the dedupe key.
GoldCohorts, quality measures, risk features, AI training setsJoin on normalized references; de-identify before data leaves the clinical boundary.
QuarantineError-file OperationOutcomes, unparseable linesAlert 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 needBest fitWhy
Cohort history before go-liveBulk FHIR Group exportOne async job instead of thousands of paged searches
Weekly or monthly quality, risk or registry refreshBulk FHIR with _type, _typeFilter, _since where supportedMatches batch cadence and vendors' stated good uses
Admits, discharges, transfers within minutesHL7v2 ADT feedEvent-driven; the feed most hospitals already run
React to specific resource changesFHIR Subscriptions where supportedPush instead of polling; support varies by vendor
One patient during a workflowPer-patient US Core REST queriesSynchronous 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 200 with a populated error array 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 kid before 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 requiresAccessToken type change breaks strict parsers. Fix: tolerant parsing and a contract test per customer environment.

Bulk FHIR production checklist

  1. Confirm Bulk operations and parameters in each server's CapabilityStatement and vendor docs.
  2. Register a backend client per environment with a JWKS URL.
  3. Send the Group definition, refresh cadence, size and export frequency to the health system early.
  4. On Epic, agree the request window if one run per group per day is not enough.
  5. Always send Accept and Prefer: respond-async, and always set _type.
  6. Use _typeFilter for clinical windows and _since only for change tracking.
  7. Persist the status URL, honor Retry-After and 429, and back off exponentially.
  8. Mint short-lived tokens per call so expiry never kills a job.
  9. Download output and error files immediately, verify counts, then DELETE.
  10. Store manifest transactionTime as the next watermark.
  11. Land NDJSON immutably, dedupe in silver, and apply deleted tombstones.
  12. 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.

Ready to scale?

Talk to our healthcare engineering team about building, integrating, and shipping faster.

Frequently Asked Questions

What is Bulk FHIR used for?

Bulk FHIR exports data for many patients in one asynchronous job instead of thousands of individual API calls. Teams use it to seed analytics warehouses, build AI training datasets, run quality measures and risk adjustment, and submit registry data. The output is NDJSON, one or more files per resource type, which loads cleanly into Parquet or a FHIR store. It is a batch tool, so pair it with event feeds when you need near-real-time data.

Does Epic support _since for Bulk FHIR exports?

No. Epic's Bulk Data tutorial states that it supports only the Group export operation and does not support _since. It does support _type, includeAssociatedData and, from the November 2023 version, _typeFilter. By default a client can request a given Group once every 24 hours, and results are deleted 14 days after kick-off. Epic lists incremental loads and warehouse synchronization as poor use cases for Bulk Data.

How do I get a Group ID for a Bulk FHIR export?

On Epic and Oracle Health you request one from the health system. Epic customers typically define a registry or roster and authorize your client for that Group, while Oracle Health organizations manage groups in Ignite Management Tooling. athenahealth uses the practice as the Group, with an ID formatted a-1.c-[practiceId]. Send inclusion criteria, expected size and export frequency early, because Group provisioning often takes longer than the code.

What authentication does Bulk FHIR use?

Bulk FHIR uses SMART Backend Services: an OAuth 2.0 client credentials grant where your service signs a JWT with its private key, using RS384 or ES384, and posts it to the token endpoint. The EHR verifies the signature with the public keys you registered, ideally through a JWKS URL, and returns a short-lived bearer token with system scopes. ONC's (g)(10) criterion requires certified EHRs to support this for system-scope access.

How long are Bulk FHIR export files available?

It depends on the vendor. The spec only says servers SHOULD send an Expires header with the completed manifest. Epic gives you 14 days from kick-off to finish and download, after which the data is deleted. Oracle Health expires exported content after 30 days. athenahealth keeps exported data for the lesser of 30 days after completion or 60 days after the request, and its download URLs expire 60 minutes after you fetch the manifest. Download files as soon as the job completes, store them, then call DELETE on the status URL.

Is Bulk FHIR required for ONC certification?

Yes, for Group export. Under 45 CFR 170.315(g)(10), certified health IT must respond to requests for multiple patients' data as a group using the Bulk Data Access standard adopted at 170.215(d), which includes mandatory support for the group-export operation, and must authorize system-scope access through SMART Backend Services. System-level export, Patient-level export and parameters such as _since are optional, which explains why vendor support varies.
Share