Guides
Events and webhooks
Get signed webhooks when a check completes. The event envelope, how to verify a signature, retries, replay, and what at-least-once delivery means for your code.
An event says that something happened, and carries the facts about it. Claim House records an event in the same transaction as the change it describes, so it never describes something that did not happen. Events are stored and can be listed and replayed. Then it sends each event to the webhook endpoints you registered.
Events hold facts, never a patient's name, date of birth, member ID or group number. For an eligibility check, the event names the check: read the check by its ID for the answer. For a claim, the event carries the claim's ID, office, payer, state and previous state, and the file or status that moved it: read the claim by its ID for the rest.
Event types#
| Event type | About | When it is sent |
|---|---|---|
eligibility.completed | eligibility_check | An eligibility check finished: answered, rejected, payer unavailable or error. Fetch the check by its ID for the answer. |
api_key.created | api_key | An API key was created. |
api_key.revoked | api_key | An API key was revoked. |
claim.validated | claim | A claim passed validation. Fetch the claim by its ID for the findings. |
claim.needs_attention | claim | A claim failed validation and was not queued. Fetch the claim by its ID for the findings. |
claim.queued | claim | A claim is waiting for the next batch: it was queued, returned to the queue after a rejected file, or is a void transaction. |
claim.submitted | claim | A claim went out in an 837D file. The event names the file and its control numbers. |
claim.accepted | claim | The payer accepted the claim (a 277 status). The event carries the status category and code. |
claim.rejected | claim | The clearinghouse or the payer rejected a claim. The event carries the status category and code; fetch the claim for the reasons in plain words. |
claim.status_updated | claim | A status arrived for a claim that did not change its state (for example a 997 after the 277). Fetch the claim's timeline for it. |
claim.voided | claim | A claim was voided: before it was sent, or because the payer accepted a void transaction. |
batch.acknowledged | batch | The clearinghouse acknowledged a claim file (a 997): accepted, or rejected, which puts every claim in it back in the queue. The event names the file and its control numbers. |
attachment.completed | attachment | An attachment was closed and sent: the network gave it a number. The event carries the number, the payer, the kind and how many documents it holds; fetch the attachment by its ID for the rest. |
payer_request.received | payer_request | A payer asked for more information about a claim. The event carries the payer, its reference number and the due date; fetch the request by its ID for what is asked. |
era.received | era | A remittance (835) arrived for your organization: an ERA, with each claim payment matched to its claim or left unmatched. The event carries the payment's total, trace number and counts; fetch the ERA by its ID for the claims and lines. |
claim.paid | claim | A payer paid a claim (an ERA), or corrected an earlier payment. The event carries the ERA and the amounts paid, allowed and owed by the patient. |
claim.denied | claim | A payer denied a claim (an ERA). The event carries the ERA; fetch the ERA for the reasons in plain words. |
claim.payment_reversed | claim | A payer took back its payment of a claim (an ERA reversal): the claim is back to accepted by the payer. If you had posted the payment, undo the posting. |
predetermination.returned | claim | A predetermination's estimate came back (an ERA with status 25, pricing only): it is returned. The event carries the ERA and what the payer would allow, would pay and the patient's share; never a payment. |
More event types arrive with the products they belong to. Subscribe to the types you want, or leave the list out to receive every type, including the ones added later.
The envelope#
Every event has the same envelope, in the API and in a webhook:
{
"id": "evt_...",
"object": "event",
"type": "eligibility.completed",
"sequence": 1,
"created_at": "2026-03-10T15:30:00.430000+00:00",
"data": {
"id": "elg_...",
"object": "eligibility_check",
"office_id": "off_...",
"payer": { "id": "pyr_...", "payer_id": "...", "name": "..." },
"state": "completed",
"outcome": "answered",
"status": "active",
"billing": { "charged": false, "state": "not_billable", "reason": "test_mode", "label": "Not billed: test mode" },
"cache": { "state": "fresh", "source_id": null },
"parent_id": null,
"request_id": "req_...",
"tenant_reference": "visit-1042",
"created_at": "2026-03-10T15:30:00.250000+00:00",
"completed_at": "2026-03-10T15:30:00.430000+00:00"
}
}sequenceis the event's place in the history of its resource: 1, 2, 3 and so on, with no gaps. Events of one resource can arrive out of order, so sort bysequence.datais stored when the event is recorded and never changed. What the API returns, what is delivered and what is replayed are exactly the same.
A claim's lifecycle in events#
A claim sends an event at each step, in this order, each numbered by sequence within the claim:
claim.validatedorclaim.needs_attention, when the claim is checked (onPOST /claims, a correction or a resubmission).claim.queued, when it waits for the next batch (again after a file the clearinghouse rejected).claim.submitted, when it goes out in an 837D file:data.filenames the file and its control numbers.batch.acknowledged, once per file, when the 997 comes back: accepted, or rejected (its claims are queued again). It is about the file, not one claim.claim.acceptedorclaim.rejected, from the payer's 277:data.statushas the category and code; the claim has the reason in plain words.claim.status_updated, for a status that does not change the state (pending at the payer, or a late status after a later one).claim.voided, when a claim is voided before it is sent, or when the payer accepts its void transaction.era.received, once per remittance (an ERA) that arrives for your organization, thenclaim.paidorclaim.deniedfor each claim it pays or denies:data.paymentnames the ERA and this payment's amounts (paid,allowed,patient_responsibility, as decimal strings), anddata.claim_paymentthe claim's figures after it (outcome, thepaidandpatient_responsibilityin all, and theallowedof its current payment), so you can post from the event alone. A second payment of the claim (a correction) sendsclaim.paidagain; a payment the payer takes back sendsclaim.payment_reversed. See ERAs and posting.
{
"id": "evt_...",
"object": "event",
"type": "claim.submitted",
"sequence": 3,
"created_at": "2026-09-24T21:00:30.000000+00:00",
"data": {
"id": "clm_...",
"object": "claim",
"office_id": "off_...",
"payer": { "id": "pyr_...", "payer_id": "...", "name": "..." },
"state": "submitted",
"previous_state": "queued",
"frequency": "original",
"parent_id": null,
"request_id": "req_...",
"file": { "name": "20260924210030000.837", "interchange_control_number": "000000042", "group_control_number": "42", "transaction_control_number": "0042" },
"status": null,
"created_at": "2026-09-24T21:00:00.120000+00:00",
"occurred_at": "2026-09-24T21:00:30.000000+00:00"
}
}See Claims for what each state means and what to do next.
Add an endpoint#
Add an endpoint in the dashboard (Developers, Webhook endpoints) or with POST /webhook_endpoints:
curl "https://sandbox.myclaimhouse.com/api/v1/webhook_endpoints" \
-H "Authorization: Bearer $CLAIMHOUSE_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/claimhouse",
"description": "Billing system",
"event_types": ["eligibility.completed"]
}'The reply carries the endpoint's signing secret, whsec_.... It is shown once, in this reply and in the reply to rotating the secret, and nowhere else: store it now. The copy kept for an Idempotency-Key replay has no secret and says "secret_available": false: rotate the secret to get a new one.
An endpoint belongs to one organization and one mode. A test key's endpoints receive the events of test checks, and a live key's endpoints receive live ones. An organization can have at most 10 endpoints in a mode.
Where an endpoint may point#
The rules are the same in test and live mode, because a test key is available to anyone who signs up.
- The URL is
httpsto a host name. No IP addresses, no user name or password, no fragment. - The host name must resolve to a public address. Addresses that are loopback, private, link-local, unique-local, shared (carrier-grade NAT), multicast or otherwise not public are refused, including a public-looking name that resolves to one.
- This is checked when you add or enable the endpoint, and again before every delivery. A name that stops resolving to a public address stops receiving events.
- Redirects are never followed. Point the endpoint at the final address.
A URL that breaks a rule is refused with 422 INVALID_REQUEST and a message on the url field. The answer for a host that does not resolve and for one that resolves somewhere that is not public is the same.
To receive webhooks on a machine of your own while you develop, put a tunnel service in front of it: it gives your local server a public https address.
What a delivery looks like#
Claim House sends a POST to your URL with the envelope as the JSON body, exactly as stored, and these headers:
| Header | Value |
|---|---|
X-ClaimHouse-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See below. |
X-ClaimHouse-Event | The event type, such as eligibility.completed. |
X-ClaimHouse-Delivery | The ID of this delivery (whd_...). |
User-Agent | ClaimHouse-Webhooks/1 |
Content-Type | application/json |
Any 2xx response is a success. Anything else, a connection that fails, or no answer in 10 seconds is a failure. Answer quickly: store the event, return 200, and do the work afterwards.
Verify the signature#
Check the signature of every request before you trust it. Anyone can POST to your URL, but only Claim House knows the signing secret.
The header is t=<unix seconds>,v1=<signature>. The signature is the hex HMAC-SHA256, keyed with the endpoint's signing secret, of the text "<t>.<raw body>": the timestamp, a dot, and the request body exactly as it arrived.
- Read the raw body, as text. Do not parse it and write it out again: the signature covers the exact bytes.
- Split the header into
tandv1. - Refuse the request when
tis more than 5 minutes from your clock. This stops someone replaying a captured request later. - Compute the HMAC of
"<t>.<raw body>"and compare it withv1in constant time.
A complete receiver in TypeScript, for any server that gives you a Fetch API Request (Next.js route handlers, Hono, Bun, Deno, Cloudflare Workers):
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
// True when `header` is a valid signature of `rawBody` made with `secret`.
export function verifyClaimHouseSignature(input: { secret: string; header: string | null; rawBody: string; nowSeconds?: number }): boolean {
const match = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(input.header ?? '')
if (!match) return false
const timestamp = Number(match[1])
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000)
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false
const expected = createHmac('sha256', input.secret).update(`${timestamp}.${input.rawBody}`).digest()
const presented = Buffer.from(match[2], 'hex')
return presented.length === expected.length && timingSafeEqual(presented, expected)
}
// Deliveries already handled. Use a database table with a unique key in production.
const handled = new Set<string>()
export async function POST(request: Request): Promise<Response> {
const rawBody = await request.text()
const valid = verifyClaimHouseSignature({
secret: process.env.CLAIMHOUSE_WEBHOOK_SECRET!,
header: request.headers.get('X-ClaimHouse-Signature'),
rawBody,
})
if (!valid) return new Response('Invalid signature', { status: 400 })
// At least once: the same delivery can arrive more than once.
const deliveryId = request.headers.get('X-ClaimHouse-Delivery')!
if (handled.has(deliveryId)) return new Response(null, { status: 200 })
handled.add(deliveryId)
const event = JSON.parse(rawBody) as { id: string; type: string; sequence: number; data: { id: string } }
console.log(`${event.type} for ${event.data.id} (sequence ${event.sequence})`)
// Store the event, answer quickly, and do the work afterwards.
return new Response(null, { status: 200 })
}Keep the secret in your server's configuration, never in code or a repository.
Retries#
A failed delivery is tried again, up to 8 attempts in all over about 24 hours, while the delivery worker runs. After a failed attempt the next one is due, in turn: 1 minute, 5 minutes, 15 minutes, 45 minutes, 2 hours, 6 hours, 14 hours 54 minutes later. A retry goes out when the delivery worker next runs after it is due, so it can be later than that. After the last failed attempt the delivery is exhausted and is not tried again. You can replay the event.
At least once#
Delivery is at least once, not exactly once. If Claim House sends a delivery and then fails before it records your answer, it sends the delivery again. So:
- Deduplicate by the
X-ClaimHouse-Deliveryheader (or the eventid), as the sample does, before you act on an event. - Make what you do with an event safe to repeat.
- Do not rely on order across events: use
sequencefor events of one resource.
Replay an event#
POST /events/{id}/replay sends an event again, as a new delivery to each endpoint that is enabled and subscribed to its type now. Use it when an endpoint was down, or after you fixed a bug. A replay is signed and delivered like the first one, and carries a new X-ClaimHouse-Delivery.
A replay is refused with 429 (TOO_MANY_REQUESTS) after 20 replayed deliveries of one event, or 200 for an organization, in an hour. You can also replay from the Events page of the dashboard.
Read events#
GET /events lists the events of your organization and mode, newest first. Filter by type, by resource_id (an eligibility check or an API key) or by created_after. GET /events/{id} reads one. If you suspect you missed a webhook, list events instead of waiting.
curl "https://sandbox.myclaimhouse.com/api/v1/events?type=eligibility.completed&limit=5" -H "Authorization: Bearer $CLAIMHOUSE_KEY"Change or remove an endpoint#
- Rotate the signing secret to replace it. The old secret stops signing at once, so deploy the new one first or accept a short gap.
- Delete an endpoint to stop deliveries to it.
- Disable an endpoint (or use the dashboard) to pause it without losing its settings. While it is disabled, its queued deliveries wait and new events are not queued for it. Enable it again to resume: its URL is checked again first, the deliveries that waited are sent, except those that waited more than 24 hours (counted from the event, or from the replay for a replayed delivery), which are marked
exhausted. Replay what you missed. - List and get endpoints. The signing secret is never included.