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](mailto:support@medplum.com) to get access to these bots.

## Overview

After you [submit a professional claim](/content/docs/integration/stedi/claim-submission/professional-claims/index.html), 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](https://www.stedi.com/docs/healthcare/api-reference/get-healthcare-reports-277) or [835 report](https://www.stedi.com/docs/healthcare/api-reference/get-healthcare-reports-835) APIs (and, for 835, the [ERA PDF](https://www.stedi.com/docs/healthcare/api-reference/get-era-pdf)).

### Storage model

Reports are stored with **minimal translation**. Each report is saved **verbatim** as a [DocumentReference](/content/docs/api/fhir/resources/documentreference/index.html):

- **No** [ClaimResponse](/content/docs/api/fhir/resources/claimresponse/index.html) or [PaymentReconciliation](/content/docs/api/fhir/resources/paymentreconciliation/index.html) 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 `ClaimResponse` already exists (from claim submission), the report `DocumentReference` is **best-effort linked** back to it via an extension (see [ClaimResponse linking](/content/docs/integration/stedi/claim-submission/claim-responses#claimresponse-linking/index.html)).

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):

```http
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 `ClientApplication` was 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](https://www.stedi.com/docs/api-reference/edi-platform/core/get-pollingtransactions) 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**:

```http
POST {base}/fhir/R4/ClaimResponse/$stedi-poll-responses
```

```ts
const result = await medplum.post(

medplum.fhirUrl('ClaimResponse', '$stedi-poll-responses')
);
```

The operation returns a summary of the run:

```json
{

"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:

```http
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` — no `ClaimResponse`/`PaymentReconciliation` mapping (only a best-effort link to an existing `ClaimResponse`)
- No `file.failed.v2` alerting
- No Real-Time Claim Status (276/277) API
- Provider UI status display is not included (query the stored `DocumentReference` resources from your app)
