Claim Responses (277 / 835) | Medplum
This guide explains how Medplum ingests Stedi inbound claim responses — 277CA claim acknowledgments and 835 Electronic Remittance Advice (ERA) — and how to set up the two delivery paths: webhooks (the primary, near-real-time path) and an optional poller (a catch-up safety net).
This workflow is handled by the Stedi claim-response bots. Please contact the Medplum team to get access to these bots.
Overview
After you submit a professional claim, payers respond asynchronously:
| Response | X12 | Meaning |
|---|---|---|
| Claim acknowledgment | 277CA | The clearinghouse/payer accepted or rejected the claim for processing |
| Remittance / payment | 835 ERA | Adjudication and payment details for the claim |
Stedi notifies you when a response is ready. The notification delivers only a transactionId — Medplum then fetches the full report from Stedi's 277 report or 835 report APIs (and, for 835, the ERA PDF).
Storage model
Reports are stored with minimal translation. Each report is saved verbatim as a DocumentReference:
- No ClaimResponse or PaymentReconciliation resources are created from the response, and no adjudication/payment fields are mapped. Downstream consumers parse the stored report JSON as needed.
- As a convenience, when a matching
ClaimResponsealready exists (from claim submission), the reportDocumentReferenceis best-effort linked back to it via an extension (see ClaimResponse linking).
transaction.processed.v2 (transactionId)
fetch 277/835 report
(
- ERA PDF for 835)
best-effort link
Delivery path
Webhook bot
(primary)
Poller bot
(catch-up)
Stedi
DocumentReference
(raw report JSON)
existing ClaimResponse
Bots and operations
| Bot identifier | Operation | Role |
|---|---|---|
stedi-claim-response-webhook |
$stedi-claim-response-webhook |
Receives Stedi transaction.processed.v2 deliveries. In production Stedi calls it directly via its identifier-based $execute URL; the operation is provided for manual replay/testing |
stedi-claim-response-poller |
$stedi-poll-responses |
Catch-up: polls Stedi for inbound 277/835 transactions missed by the webhook |
install-stedi |
$stedi-install |
One-time setup: creates the webhook ClientApplication, prints the webhook URL, and optionally sets project secrets |
What gets stored
| Transaction | DocumentReference(s) created |
|---|---|
| 277 | One DocumentReference (application/json) containing the raw 277 report |
| 835 | One DocumentReference (application/json) containing the raw 835 report, plus (best-effort) one DocumentReference (application/pdf) for the ERA PDF |
Each stored DocumentReference carries these identifiers:
| System | Value |
|---|---|
https://www.stedi.com/transactions/inbound |
The inbound Stedi transaction id (the ERA PDF uses <transactionId>/pdf) |
https://www.stedi.com/response-type |
277 or 835 |
https://www.stedi.com/events |
The originating webhook event id (present only when delivered via webhook) |
ClaimResponse linking
The submit-claim bot stamps the patient control number (X12 CLM01) onto the Claim as an identifier with system https://www.stedi.com/patient-control-number. The payer echoes that same value back on the 277/835, so the claim-response flow can correlate an inbound report to the originating Claim and its ClaimResponse.
When a match is found, a flat extension is added to the ClaimResponse referencing the stored report DocumentReference:
| Transaction | Extension URL on ClaimResponse |
|---|---|
| 277 | https://www.stedi.com/fhir/StructureDefinition/claim-response-277-report |
| 835 | https://www.stedi.com/fhir/StructureDefinition/claim-response-835-report |
Linking is best-effort: if the control number, Claim, or ClaimResponse cannot be found, the report is still stored in full as a DocumentReference — no data is lost. A single claim per transaction is assumed (only the first control number found in a report is correlated), which matches how claims are submitted (one Claim per Stedi transaction).
Idempotency
Processing is idempotent on the Stedi inbound transactionId (not the webhook event.id). Before fetching a report, the flow checks whether a DocumentReference already exists with identifier https://www.stedi.com/transactions/inbound|<transactionId>; if so, the transaction is skipped.
This makes it safe for the webhook and the poller to overlap — re-processing the same transaction is a no-op — and safely ignores webhook replays delivered with a new event.id.
Setting up webhooks
Webhooks are the primary, near-real-time delivery path. Setup follows four steps: run the install bot, grab the client credentials it creates, create the webhook in Stedi, and add the transaction-processed event binding.
Step 1 — Run the Stedi Install bot in your own project
As a project admin, invoke the $stedi-install operation at the base of your project. This runs the Stedi Install bot, which creates a ClientApplication that Stedi uses to authenticate its webhook calls and prints the exact webhook URL to configure. You can optionally set the Stedi project secrets at the same time (any omitted secret is left untouched, so this is safe to re-run):
POST {base}/fhir/R4/$stedi-install
Content-Type: application/fhir+json
{
"resourceType": "Parameters",
"parameter": [
{ "name": "STEDI_CLAIM_API_KEY", "valueString": "<YOUR_STEDI_CLAIM_API_KEY>" },
{ "name": "STEDI_INSURANCE_API_KEY", "valueString": "<YOUR_STEDI_INSURANCE_API_KEY>" },
{ "name": "STEDI_CLAIM_TEST_MODE", "valueBoolean": false }
]
}
The operation is idempotent — re-running it does not create duplicate clients. It returns an OperationOutcome whose text summarizes:
- Whether the webhook
ClientApplicationwas created or already existed - Which secrets (if any) were set
- The webhook URL to configure in Stedi
- Next-step instructions
Polling (catch-up)
The poller is an optional safety net that backfills any responses the webhook missed. It polls Stedi's Poll Transactions API for INBOUND 277/835 transactions since the last checkpoint and stores each report the same way the webhook does. It is idempotent on the inbound transaction id, so it never double-stores a report the webhook already ingested.
Running the poll operation
Invoke $stedi-poll-responses at the type level. It takes no input:
POST {base}/fhir/R4/ClaimResponse/$stedi-poll-responses
const result = await medplum.post(
medplum.fhirUrl('ClaimResponse', '$stedi-poll-responses')
);
The operation returns a summary of the run:
{
"ok": true,
"since": "2026-06-24T00:00:00.000Z",
"checkpoint": "2026-07-01T17:05:00.000Z",
"processedCount": 3,
"skippedCount": 12
}
| Field | Meaning |
|---|---|
since |
Start of the poll window (the previous checkpoint) |
checkpoint |
New checkpoint timestamp, captured before fetching so transactions arriving mid-run are not skipped next time |
processedCount |
Inbound 277/835 transactions newly stored on this run |
skippedCount |
Transactions skipped (already processed, or not inbound 277/835) |
Querying stored reports
Stored reports are DocumentReference resources, searchable by the identifiers above:
GET {base}/fhir/R4/DocumentReference?identifier=https://www.stedi.com/response-type|277
GET {base}/fhir/R4/DocumentReference?identifier=https://www.stedi.com/response-type|835
GET {base}/fhir/R4/DocumentReference?identifier=https://www.stedi.com/transactions/inbound|<transactionId>
When a report was linked to a ClaimResponse, you can also reach it from the ClaimResponse extension (…/claim-response-277-report or …/claim-response-835-report).
Limitations
- Professional (837P) claims only
- Reports are stored verbatim as
DocumentReference— noClaimResponse/PaymentReconciliationmapping (only a best-effort link to an existingClaimResponse) - No
file.failed.v2alerting - No Real-Time Claim Status (276/277) API
- Provider UI status display is not included (query the stored
DocumentReferenceresources from your app)