Claim Chaser Docs
Claim Chaser API — Partner Onboarding Guide
Who this is for: developers and OEM partners (e.g. DentTracks) provisioning practices into Claim Chaser from their own software. This walks the full provisioning journey — office → doctor → carrier → credentialing → claims → callable — entirely over HTTP, with no browser steps except the few that are deliberately in-app only.
The authoritative spec for every request/response shape, field, and error is the API Reference. This guide is the how; the API Reference is the what — when they disagree, the API Reference wins.
The self-serve provisioning endpoints in Steps 3–5 — POST /api/doctors, POST /api/offices, POST /api/carrier-requests, POST /api/doctor-carriers/turn-on, and GET /api/carriers — are live in production and documented in the API Reference. They implement the create-or-match soft-match contract described below.
Before you start — you need an account first
The API key everything below depends on is minted inside the app, so a person must create the account and accept the legal agreements before any of this is scriptable. The full five-minute walkthrough is Step 0 — from nothing to a working API key. In brief:
- Sign up at app.claimchaser.ai/login. There is no separate signup page —
/signupand/registerboth redirect to/login. Use the "Sign up" toggle under the login form. - Create your organization, then accept the agreements. A Business Associate Agreement (BAA) — with Terms and Privacy — gates the entire app, including the Developer tab, until you accept it. Nothing below is reachable before that.
- Once you're in, mint an API key in the Developer tab (§2).
1 · The mental model
The provisioning journey is a fixed order because each step depends on the one before it:
- Create office(s) — name, address, callback number, EIN.
- Create doctor(s) — name, NPI; link to an office. Office-first: a doctor references an
office_id, so the office must exist first. - Find or request each carrier — match our catalog; if it's not there, request it (we provision, and fire a webhook when it resolves).
- Credential — connect each doctor to their in-network carriers (
doctor-carriers). This is what makes a claim callable. - Upload claims referencing
doctor_id/office_id/carrier_id. - Check callability — if a claim can't be called, the API tells you why.
Everything is soft-matched: creating an entity that already exists returns the existing one (or a candidate list) instead of duplicating it.
2 · Getting a key
No account yet? Start with Step 0 — from nothing to a working API key. This section assumes you already have a login, an organization, and signed agreements. Step 0 covers those four prerequisites in about five minutes, entirely self-serve.
- In the app's Developer tab, generate a key. Give it a name and select the scopes your integration needs.
- The full key (format
cc_live_…) is shown once — copy it and store it safely. We keep only a hashed copy; you can't retrieve it again. - Send it on every request as the
x-api-keyheader. No password, no browser session.
curl -H "x-api-key: cc_live_YOUR_KEY_HERE" \
https://app.claimchaser.ai/api/claims
- Base URL:
https://app.claimchaser.ai - A key is bound to one organization. Any
organization_idyou send in a query or body is ignored — the org comes from the key. - Revoke anytime in the Developer tab; revocation takes effect on the very next request (no caching). Keys can carry an expiry.
The three scopes
| Scope | Grants |
|---|---|
read | All read endpoints (claims, calls, usage, carriers, doctors, offices, upload context). |
write | Adds claim upload/update and the provisioning endpoints (offices, doctors, carrier-requests, doctor-carrier links). |
calls:write | Adds triggering calls (POST /api/make-call). Carries a per-day call cap (default 50, max 500). |
Scopes are cumulative — a calls:write key can also read and write. Assign the least a given integration needs.
Two budgets to know about
- Daily write budget — a per-key daily counter (default 300/day) gates the provisioning writes only:
POST /api/offices,POST /api/doctors, andPOST /api/carrier-requests. It does NOT coverPOST /api/uploads/claims(claim upload/update) orPOST /api/doctor-carriers/turn-on— do not size your claim import against this cap; those two routes are not counted by it. Exceed the budget on a covered route and you get429with"code":"WRITE_CAP_REACHED"— wait for the daily reset. Distinguish that from503 WRITE_CAP_UNAVAILABLE(the write-counter backend was briefly unavailable and the request failed closed): a503is transient — back off and retry. - Daily call cap — a
calls:writekey has its own per-day ceiling onmake-call(see §7).
3 · Create offices and doctors (create-or-match)
Both endpoints implement the same soft-match contract, so you can call them idempotently — re-running your provisioning script won't create duplicates.
The soft-match contract (read this once)
When you POST an entity, one of four things happens:
| Outcome | Response | What it means |
|---|---|---|
| Matched on identifier | { "matched": true, "id": "…", "match_type": "npi" } | An exact identifier match within your org. For doctors, NPI is matched org-scoped. |
| Matched on name | { "matched": true, "id": "…", "match_type": "name" } | A learned mapping or a high-confidence fuzzy name match (score ≥ 0.95). |
| Ambiguous | 200 { "matched": false, "candidates": [ { "id": "…", "name": "…", "score": 0.9 } ], "next": "resolve_to <id> or create:true" } | The name landed in the grey band (below 0.95). We never silently create — you decide. |
No match, no create flag | 200 { "matched": false, "candidates": [] } | Nothing matched and you didn't send create:true. Empty candidates, no id, and nothing was created. This is the first-run result for a brand-new office or doctor — send create:true to actually make the record. |
You sent create:true | 201 { "created": true, "id": "…" } | The only input that reaches the create branch. Validated, then inserted, org bound from the key. |
⚠️ A bare POST never creates — it proposes. It matches if it can; otherwise it returns
matched: falsewith acandidateslist (empty on a brand-new name) and noid."create": trueis the only input that reaches the create branch, whether candidates exist or not — so you send it the first time you provision every office and doctor, when there is nothing to match yet. It is not merely an override for ambiguous results.
create:trueapplies to offices and doctors only — not carriers. A carrier you don't already have isn't created this way; it's raised as a carrier request and reviewed by us before it becomes callable (§4). There is nocreate:trueon that path — sending it has no effect.
When a result comes back ambiguous (candidates present), you have two ways to resolve it:
"create": true— none of these; make a new record anyway."resolve_to": "<id>"— "no, it's this existing one." Returns that id and records the mapping, so the same input auto-matches next time. This is how the learned name-to-record matching grows over headless use — the mapping is written only on an explicitresolve_to, never on a bare fuzzy hit.
Identifier notes:
- Doctor NPI is the strong signal — matched exactly, scoped to your org. The same NPI in another org is a separate record (no cross-org collision).
- Office EIN is not unique — an EIN match is treated as a candidate signal, not an auto-link. You'll get it back in
candidates, not an auto-match.
Validation (422): missing or malformed fields return 422 in this exact shape — a missing list (empty/absent fields) and an invalid list (present-but-malformed), each entry naming the field so a partner knows exactly what to fix:
{
"error": "…",
"missing": [ { "field": "ein", "label": "EIN" } ],
"invalid": [ { "field": "npi", "label": "NPI", "error": "…" } ]
}
Field rules: NPI = exactly 10 digits, EIN = exactly 9 digits, callback number = 10 digits (11 if it starts with 1).
POST /api/offices (scope: write)
Do this first — doctors reference an office.
curl -X POST -H "x-api-key: cc_live_…" \
-H "Content-Type: application/json" \
https://app.claimchaser.ai/api/offices \
-d '{
"name": "Main Street Clinic",
"address": "123 Main St, Springfield",
"callback_number": "2085551234",
"ein": "123456789",
"create": true
}'
"create": true is required the first time. On a brand-new organization there is nothing to match, so without it you get 200 { "matched": false, "candidates": [] } and no id — nothing is created, and the doctor step below then has no office_id to reference. With it, the office is created:
{ "created": true, "id": "OFFICE_ID" }
Re-run the same office without create and it returns the existing record instead of duplicating — which is what makes the script safe to re-run:
{ "matched": true, "id": "OFFICE_ID", "match_type": "name" }
POST /api/doctors (scope: write)
curl -X POST -H "x-api-key: cc_live_…" \
-H "Content-Type: application/json" \
https://app.claimchaser.ai/api/doctors \
-d '{
"name": "Dr. Smith",
"npi": "1234567890",
"office_id": "OFFICE_ID",
"create": true
}'
As with offices, "create": true is what reaches the create branch — send it the first time you provision each doctor:
{ "created": true, "id": "DOC_ID" }
If a similar name already exists in your org, you get candidates instead of a create, and must round-trip:
{
"matched": false,
"candidates": [ { "id": "DOC_ID", "name": "Dr. J. Smith", "score": 0.91 } ],
"next": "resolve_to <id> or create:true"
}
Resolve it (and teach the mapping for next time):
curl -X POST -H "x-api-key: cc_live_…" \
-H "Content-Type: application/json" \
https://app.claimchaser.ai/api/doctors \
-d '{
"name": "Dr. Smith",
"npi": "1234567890",
"office_id": "OFFICE_ID",
"resolve_to": "DOC_ID"
}'
…or force a new record:
# same body, with "create": true instead of resolve_to
Concurrency: resolve-then-insert isn't atomic, but the database has a unique constraint (
organization_id+ lowercased name) as the backstop. If two requests race to create the same office/doctor, one wins and the other returns the winner's id — you never get a duplicate.
4 · Find or request a carrier
You can't create carriers via the API (carrier provisioning is managed by Claim Chaser). You match against our catalog, and if it's absent, request it.
GET /api/carriers (scope: read)
Browse/search the catalog — your org's linked carriers plus global-catalog candidates you could request. Filter by name with the ?search= query param (case-insensitive substring).
curl -H "x-api-key: cc_live_…" \
"https://app.claimchaser.ai/api/carriers?search=acme"
Response — internal test carriers are excluded:
{
"carriers": [ { "id": "CARRIER_ID", "name": "Acme Health", "in_network": true } ],
"truncated": false
}
in_network reflects whether the carrier is already linked to your org; truncated is true when the result set was capped. (To see only the carriers already linked to your org with their phone numbers, GET /api/organization-carriers returns them.)
POST /api/carrier-requests (scope: write) — match-first
This endpoint matches first: it runs carrier-dedup against the catalog and, if it finds an exact/close match, hands you back the existing carrier_id (matched: true) and files nothing. Only if nothing matches does it file a pending request for us to provision. You cannot create a carrier via the API — only request one.
curl -X POST -H "x-api-key: cc_live_…" \
-H "Content-Type: application/json" \
https://app.claimchaser.ai/api/carrier-requests \
-d '{ "requested_name": "Acme Health" }'
- Matched →
{ "matched": true, "carrier_id": "CARRIER_ID" }— use it directly, no waiting, no request filed. - No match → a pending
carrier_requestis filed: you get back the request id andstatus: "pending"(nocarrier_id). We provision the carrier, and when it's ready we fire thecarrier.request_resolvedwebhook (see §8) so you can resume automatically. You can also pollGET /api/claims— its response includes acarrier_requestsarray ({ id, requested_name }).
5 · Credential — connect doctor ↔ carrier
This is the step that makes claims callable: it asserts a provider is in-network with a carrier. Only credentialed (doctor, carrier) pairs are ever dialed.
POST /api/doctor-carriers/turn-on (scope: write)
Idempotent — it creates or re-activates the (doctor, carrier) credentialing link, so calling it twice is safe. A kind is required (enum) — self-serve/keyed callers send "customer".
curl -X POST -H "x-api-key: cc_live_…" \
-H "Content-Type: application/json" \
https://app.claimchaser.ai/api/doctor-carriers/turn-on \
-d '{ "doctor_id": "DOC_ID", "carrier_id": "CARRIER_ID", "kind": "customer" }'
Both foreign ids are ownership-checked against your org: an id that belongs to another org returns 404 (FOREIGN_OFFICE / not-found) — the org-ownership gate refuses it existence-neutrally, so you can't probe another org's records. Verify the resulting links anytime:
curl -H "x-api-key: cc_live_…" \
"https://app.claimchaser.ai/api/doctor-carriers?carrier_id=CARRIER_ID"
Trust model: credentialing is partner-asserted — the same model as the in-app intake sheet. You're telling us which carriers each provider is credentialed with; we call only those. List only carriers a provider can actually be paid by, or you'll burn (free-to-you, cost-to-us) out-of-network calls.
6 · Upload claims
POST /api/uploads/claims (scope: write). Send "action":"insert" and a claims array. Your org is bound from the key — don't send organizationId. Resolve valid carrier_id / office_id / doctor_id from GET /api/uploads/context.
curl -X POST -H "x-api-key: cc_live_…" \
-H "Content-Type: application/json" \
https://app.claimchaser.ai/api/uploads/claims \
-d '{
"action": "insert",
"claims": [
{
"claim_number": "CLM-1001",
"patient_name": "Jane Doe",
"date_of_birth": "1985-04-12",
"subscriber_id": "W123456789",
"insurance_phone": "8005551234",
"carrier_id": "CARRIER_ID",
"office_id": "OFFICE_ID",
"doctor_id": "DOC_ID",
"date_of_service": "2026-05-01",
"billed_amount": 420.0,
"claim_status": "Denied"
}
]
}'
- Only
patient_nameis strictly required to insert — but the recommended fields above are what make a claim callable, so send everything your PMS has. insurance_phoneis normalized to digits; a matchingcarrier_idmay be resolved from it.- Dates are
YYYY-MM-DD.billed_amountis a JSON number, not a currency string. - Enter subscriber/member IDs exactly as printed, letters included (e.g. a leading
W) — the agent speaks the letters to the rep.
claim_status— how values are matched
claim_statusis matched case-insensitively with surrounding whitespace trimmed —Denied,DENIED, andDeniedall resolve to the same status, so an uppercase PMS export is fine. A value the API doesn't recognise is not rejected and not stored blindly: it is dropped, the claim keeps the defaultOpenstatus (which is dialable), and every ignored value is reported on the upload response inignored_status_values— an array of{ row, claim_number, value }objects naming which row and which word were ignored, so a bad status on row 12 of a 500-row upload is findable. The rest of the upload still lands. (Which statuses are actually dialable is a separate question — see §7.)
Open·Pending·Denied·Payment Pending·Paid·No Claim on File·Incorrect Claim Details·Duplicate Claim·Pending Resubmission·Need Revision·Bad Phone Number
Bad Phone Numberis system-written — we normally set it ourselves. You may receive it (e.g. reading back a claim we flagged) and round-trip it unchanged, but do not originate it on a claim you are inserting.
Row limits when reading claims
GET /api/claims returns at most 1,000 claims per request, newest first. The response carries a
top-level truncated boolean — true means there are more claims than were returned, not
that any were lost. GET /api/carriers behaves the same way (capped at 500).
If you see
"truncated": true, do not treat the response as a complete picture of the practice's AR. Reconciling against a truncated list will under-count. Narrow the request or contact us — cursor pagination is on the roadmap and we will publish it here.
Updating claims: send "action":"update" with an updates array of { "id": "…", "payload": { … } }. An API key may patch these fields: claim_status, insurance_phone, subscriber_id, patient_zip, patient_phone, patient_name, patient_id, date_of_birth, subscriber_name, subscriber_dob, group_number, claim_number, date_of_service, date_sent_to_insurance. Any other key — including all financial-amount fields (billed_amount, insurance_paid_amount, patient_responsibility) — is silently stripped, not errored, and reported back per-row in stripped.
Deleting claims — POST /api/claims/delete
Clean up claims you own with POST /api/claims/delete (scope: write), body { "claim_ids": ["…", …] }. What happens to each depends on whether it has call history:
- A claim with no calls is hard-deleted — removed, along with its statuses and denial reasons.
- A claim that has been called is archived, not deleted (
archived_atis set), so call history is never lost. Restore it withPOST /api/claims/unarchive. - If a claim's call history can't be confirmed, it is archived, not deleted (fail-closed).
Response: { "archived_count", "deleted_count", "ids_skipped_no_ownership" }. Ids you don't own are skipped, not errored. Send "preview": true to see what would happen — same shape, nothing changed — before running it. This is the recourse for test data: a cold run against production otherwise leaves permanent records behind with no way to remove them.
There is a second claim-create route,
POST /api/claims(single claim), separate from the batchPOST /api/uploads/claimsabove — and it behaves differently. It creates one record, can resolvedoctor_name/office_name/carrier_nameto ids, and applies create-or-match dedupe (returns the existing claim on a match). Its field allowlist also differs — keyed callers can't setinsurance_paid_amount/payment_check_number, and an unknown field returns400rather than being silently stripped. Both routes pinorganization_idfrom your key, require a USinsurance_phone, and reject a foreigndoctor_id/office_id. For imports, usePOST /api/uploads/claims(above); reach forPOST /api/claimsonly to create a single record with name-resolution.
7 · Check callability (dry-run) and trigger calls
Requires a calls:write key. Always dry-run first to confirm a claim is callable without dialing or billing.
Dry run — the callability check
curl -X POST -H "x-api-key: cc_live_…" \
-H "Content-Type: application/json" \
https://app.claimchaser.ai/api/make-call \
-d '{ "claim_id": "CLAIM_ID", "dry_run": true }'
Every eligibility gate runs for real, but no call is placed and nothing is billed. You get the dispatch plan back:
{
"success": true,
"dry_run": true,
"status": "would_dispatch",
"dispatch_blockers": [],
"plan": {
"claim_id": "…", "carrier_id": "…", "carrier_name": "Acme Health",
"to_number": "8005551234", "from_number": "…"
}
}
dispatch_blockerscollects org-state gates that would refuse a real call —VOICE_OFFandHOLIDAY_WEEKEND_FLOOR. Non-empty →statusiswould_be_blocked. A voice-off org can still build and test end-to-end this way; a dry run never dials.422 { "error": "Claim is missing required fields for a call", "missing": [ … ] }— the claim itself lacks data (e.g.date_of_birth). This is the "why can't this claim be called" answer for missing-data cases.202 { "suppressed": true, "reason": "carrier_hours" }— carrier outside its calling hours.
A dry run is blocked by the daily call cap, but never consumes it. Once a
calls:writekey hits its daily cap, even adry_runis refused with429 API_KEY_DAILY_CAP_REACHED— so the safe rehearsal is unavailable exactly when you've been most active. But a dry run does not count against the cap (it places no call and writes nocallsrow). If rehearsals are locked out, it's your real calls that spent the cap, not the dry runs.
Single-claim callability check:
GET /api/claims/{id}/callablereturns an overallcallableflag plus adispatch_blockers[]array naming each gate blocking the claim (e.g.MISSING_FIELDS,COOLDOWN,VOICE_OFF), so you can see exactly why the autodialer will or won't pick it up.
⚠️
/callableandmake-callanswer different questions — gate on the right oneThese two endpoints are not the same check.
GET /api/claims/{id}/callableanswers "would the autodialer pick this claim up on its own?" — so it includes pacing gates such asCOOLDOWNthat exist only to stop the dialer re-calling the same claim.POST /api/make-callanswers "may I dispatch this claim right now?" — a deliberate request is not paced. A claim can therefore be reported not callable and still dispatch successfully.If you are placing calls yourself, gate on the
make-calldry run, not on/callable— otherwise a soft pacing gate reads as a hard block and your integration never dials.But a genuine hard block is refused by both.
make-callstill enforces the real gates —VOICE_OFF, a claim missing required fields, no callable phone — so a claim blocked for a real reason is refused bymake-calland its dry run too, not only by/callable./callableis not noise; it is the autodialer's view, and the only difference frommake-callis the pacing gates.
Reading dispatch_blockers — the full vocabulary
callable: false is usually not a problem. What matters is which group a blocker is in, because the three groups need three different responses. Every code you can receive:
1 · Fix your claim — these are about your data
| Blocker | Meaning | What to do |
|---|---|---|
MISSING_FIELDS | the claim is missing a field a call needs | supply it. The seven fields checked are patient_name, date_of_birth, date_of_service, billed_amount, office_id, doctor_id, carrier_id |
STATUS_NOT_DIALABLE | claim_status isn't a dialable value | set an accepted status (§6) via action:"update" |
NO_CALLABLE_PHONE | no usable insurance_phone, and none resolvable from the carrier | add a valid insurance_phone, or use a carrier we can resolve a number for |
PAID | a payment amount is recorded, so we won't chase it | expected on paid claims — nothing to do |
2 · Wait — these clear on their own
| Blocker | Clears when |
|---|---|
CARRIER_HOURS | the carrier's calling hours begin |
HOLIDAY_WEEKEND_FLOOR | the next permitted day (see the note below) |
COOLDOWN | the cooldown window since the last call elapses |
3 · Act — these will not clear by waiting
| Blocker | Meaning | What to do |
|---|---|---|
VOICE_OFF | calling is switched off for your whole organization | turn it on — but read "Arming voice" below first: it is org-wide, not per-claim |
INTERNAL_TEST_CARRIER | the claim is on the sandbox test carrier | expected on sandbox claims. It keeps them out of the automatic calling queue (they never auto-dial) but does not block a direct POST /api/make-call. See "Placing a call safely" below |
More on
HOLIDAY_WEEKEND_FLOOR. Dispatch is suppressed on weekends and on US federal holidays (observed dates). The holiday half is a hard floor and cannot be overridden — no organization dials a closed payer on a federal holiday. The weekend half is on by default but can be lifted per organization: there is no API or self-serve setting for it, so contact support (or your Claim Chaser contact) to enable weekend dispatch for your org, after which it also dials Saturdays and Sundays. Which calendar day applies is evaluated in the destination number's timezone where that can be derived from the number, otherwise your organization's configured timezone. It clears on the next permitted day — the next business day for a default org, or (for an org with weekend dispatch enabled) the next non-holiday day, which may fall on a weekend.
Placing a call safely — use the sandbox carrier
⚠️ A real dispatch telephones the carrier on the claim. If that carrier is a real insurer, we call a real switchboard — real queue, real person, real money, no undo and no test mode. The catalogue is the list of insurers we can actually dial; it is not a list of examples.
To exercise the whole path including placing a call, without dialing a real payer, use the sandbox carrier Sandbox Test Carrier (Claim Chaser):
curl -s -H "x-api-key: cc_live_…" "https://app.claimchaser.ai/api/carriers?search=sandbox"
Credential a doctor to it (§5) and put it on your test claim. It is safe to dial — a direct make-call reaches our own test line, not an insurer. Choose a real carrier only when you are working real claims for a real practice. The full sandbox walkthrough is on Your First Claim.
Arming voice — org-wide, and reachable with your key
A new organization cannot place calls until calling is switched on. It is off by default. This is the VOICE_OFF blocker, and your existing write-scope key can clear it — POST /api/voice-settings with { "enabled": true } (the field is enabled; voice_on returns 400).
⚠️ The switch is org-wide. There is no per-claim scoping.
Turning voice on arms every already-callable claim in the organization, not just the one you are testing — including claims uploaded earlier, or later, for any reason. There is no confirmation prompt and no dry run for the switch itself. Before you flip it: check what would go out (
GET /api/claimsand the per-claim…/callable), make sure the practice expects calling to start, and turn it back off ({ "enabled": false }) when you're done testing. On a throwaway org with only sandbox claims this is safe; on a real org it spends real money the instant a real claim is callable.
Real dispatch
curl -X POST -H "x-api-key: cc_live_…" \
-H "Content-Type: application/json" \
https://app.claimchaser.ai/api/make-call \
-d '{ "claim_id": "CLAIM_ID" }'
{ "success": true, "status": "initiated", "call_id": "…", "claim_id": "…", "conversation_id": "…" }
Correlate the later call.completed webhook, or poll GET /api/calls/{call_id}.
Results are not synchronous — a
nullsummary right after a call is expected.ai_summaryand the extracteddenial_reasonsare populated asynchronously: from the post-call webhook and a scheduled sweep that runs about once a minute, not at the instant the call hangs up. Do not treat an emptyai_summaryimmediately after the call as a failure — it fills in shortly. Read results fromGET /api/claims/{id}/denial-reasonsafter thecall.completedwebhook arrives, rather than at hangup.
Call-path limits: daily call cap → 429 API_KEY_DAILY_CAP_REACHED (resets 00:00 UTC); per-org rate limit of 30 requests/min → 429 RATE_LIMIT_EXCEEDED with a Retry-After header (dry runs count); 403 VOICE_OFF; 402 ACCOUNT_SUSPENDED/ACCOUNT_CANCELLED.
Call duration limits. A live call is bounded server-side, so — since you're billed per minute — a long call can be told from a stuck one:
- Hard call cap: 30 minutes (1800 s). A call is cut at 30 minutes. One exception: a call that has reached a person and is actively working as it nears the cap gets a single one-time +10-minute extension, so a qualifying call can run to 40 minutes (2400 s); everything else stops at 30.
- Hold ceiling: defaults to 12 minutes (720 s). Sit on hold past the ceiling and we end the call and schedule a callback rather than keep burning your minutes in a queue. This is a configurable default, not a fixed promise — it can be tuned per carrier (bounded 5–30 minutes) or overridden per deployment, so a given carrier may use a different value.
- The voice agent also carries a 40-minute (2400 s) max-conversation backstop.
A call that ran to ~10 minutes, reports completed, and extracted nothing is the signature of a hold that reached nobody, not a failure of your integration — see Your First Claim.
8 · Webhooks — don't poll
This is a browser return trip you make after you already hold a working key — a step that comes after the HTTP journey above, not part of it. Register an HTTPS endpoint on the Developer page at app.claimchaser.ai/developer (browser-only — there is no key-reachable endpoint to create one). You get a signing secret, shown once at registration. It cannot be retrieved afterwards — there is no API or in-app way to view it again (a GET on the endpoint returns metadata only, never the secret). Copy it immediately and store it in your secret manager (e.g. CC_WEBHOOK_SECRET). If you lose it, rotate the endpoint's secret on the Developer page — that issues a fresh secret for the same endpoint (the old one stops signing); update your integration to the new secret. You do not need to re-register the endpoint, and the URL stays the same. Rotation takes effect immediately, and there is no dual-secret grace period. Deliveries signed after rotation will fail verification until the new secret is deployed in your environment — so rotate only when you can deploy the new secret right away. Failed deliveries are retried and re-signed with the current secret at each attempt, so they succeed once your environment is updated — you do not need to re-request anything. We POST an event when things happen; the payload carries IDs and status only (no PHI) — fetch the full result over the read API.
Event types
| Event | Fires when |
|---|---|
call.completed | A call finishes. This is the default subscription. |
claim.status_changed | A claim transitions into one of 6 statuses: Denied, Paid, Payment Pending, No Claim on File, Incorrect Claim Details, Duplicate Claim. Deliberately sparse — opt in via event_types. |
carrier.request_resolved | A carrier you requested (§4) has been provisioned. This closes the async loop so you can resume credentialing/uploading automatically. data: { carrier_request_id, status, carrier_id, requested_name }. |
event_types must be a non-empty array; omit it entirely for the call.completed default.
Envelope
{
"id": "call.completed:<call_id>",
"type": "call.completed",
"api_version": "2026-07-01",
"created_at": "2026-07-13T18:04:11Z",
"organization_id": "…",
"data": { "call_id": "…", "claim_id": "…", "call_status": "completed", "duration_seconds": 363 }
}
⚠️ Ordering: use
data.status_changed_at, never the envelopecreated_atTwo events for the same claim can carry a byte-identical envelope
created_at(verified in production: aDeniedthen aPaidwith the same timestamp). Ordering status writes bycreated_atcan write an older status over a newer one — e.g. reverting aPaidclaim toDenied. Forclaim.status_changed, thedataobject carriesstatus_changed_at— the moment the status actually changed. Order by that, and treat the read API as the source of truth when reconciling (see the delivery contract below).
Verifying the signature (required)
Every delivery carries Claim-Chaser-Signature: t=<unix-seconds>,v0=<hex>, where v0 is HMAC_SHA256(secret, ${t}.${rawBody}) computed over the raw request body, before JSON parsing — the timestamp t, a literal ., then the raw body. Reject anything whose t is more than 30 minutes off (replay bound).
const crypto = require('crypto')
function verifyClaimChaserSignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(signatureHeader.split(',').map((kv) => kv.split('=', 2)))
const t = Number(parts.t)
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 30 * 60) return false
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const given = Buffer.from(parts.v0 || '', 'hex')
const want = Buffer.from(expected, 'hex')
return given.length === want.length && crypto.timingSafeEqual(given, want)
}
Delivery contract
- At-least-once — dedupe on the envelope
id. - No ordering guarantee, and envelope timestamps can tie — two events for one claim can share an identical
created_at. Orderclaim.status_changedbydata.status_changed_at, not the envelopecreated_at(see the ordering note above), and treat the read API as source of truth when reconciling. - Retries — failed deliveries retry with exponential backoff for ~24h (~6 attempts), then
dead(resend from the delivery log). - Delivery latency — delivery is usually under a minute. Occasionally, under load, a delivery can be delayed by up to a couple of hours — nothing is lost, and deliveries always arrive. A missing webhook is therefore not a failed call. Webhooks stay your primary mechanism (don't poll on a timer); but if you need an answer right now and the event hasn't landed yet, poll
GET /api/calls/{call_id}as a one-off fallback — not a standing check. - Delivery timeout — 10 seconds. Each POST must return a
2xxwithin 10 seconds, or it counts as a failed delivery and is retried. Respond2xxfirst, before you do your own work, so a slow handler doesn't trip it. - Auto-disable. An endpoint that fails continuously for 24 hours is disabled automatically (the reason shows on the Developer page). Fix the endpoint, then re-enable it there. ⚠️ This is not the same as individual deliveries going
dead: the whole endpoint is switched off, and a recovered endpoint does not resume on its own — you must re-enable it in the browser. It is an unannounced browser trip that arrives during an outage, when your integration still looks alive. - Recommended flow: respond
2xximmediately → verify signature over the raw body → dedupe onid→ fetch the full result (GET /api/claims/{claim_id}/denial-reasons).
9 · What's in-app only (not on the keyed surface)
By design, a person does these in the app — there's no key-reachable endpoint:
- Carrier catalog — creating/editing carriers or their phone numbers. Keys can only request a carrier (§4).
- API-key management — minting, revoking, or changing a key's caps.
- Webhook-endpoint management — registering endpoints, editing, rotating secrets. (You register them; events flow to you automatically.)
Correction — the voice on/off switch is key-reachable. An earlier version of this page listed it here as in-app-only; that was wrong.
POST /api/voice-settingsaccepts an ordinarywritekey (§7, "Arming voice"). It remains org-wide — there is no per-claim scoping. The calling schedule is also on the keyed surface —PATCH /api/voice-settings.
Note: POST /api/doctors and POST /api/offices (create-or-match, Step 3) are on the keyed surface and live — office/doctor creation is no longer in-app-only. What stays in-app is carrier catalog creation, and API-key and webhook-endpoint management.
10 · The full journey, end to end
The end-to-end provisioning walkthrough has moved to Your First Claim — office → doctor → carrier → credentialing → claim → callable → placing the call, run start to finish against production.
⚠️ The carrier catalogue is the list of insurers we can actually dial — it is not a list of examples. If you pick a real carrier and reach the call step, we telephone that insurance company about your claim: a real switchboard, real money, no undo. While you are testing, use the sandbox carrier (
Sandbox Test Carrier (Claim Chaser)) — Your First Claim shows you how to find it.
For every exact field, status code, and error body, the API Reference is the contract.