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:

  1. Sign up at app.claimchaser.ai/login. There is no separate signup page/signup and /register both redirect to /login. Use the "Sign up" toggle under the login form.
  2. 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.
  3. 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:

  1. Create office(s) — name, address, callback number, EIN.
  2. Create doctor(s) — name, NPI; link to an office. Office-first: a doctor references an office_id, so the office must exist first.
  3. Find or request each carrier — match our catalog; if it's not there, request it (we provision, and fire a webhook when it resolves).
  4. Credential — connect each doctor to their in-network carriers (doctor-carriers). This is what makes a claim callable.
  5. Upload claims referencing doctor_id / office_id / carrier_id.
  6. 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.

  1. In the app's Developer tab, generate a key. Give it a name and select the scopes your integration needs.
  2. 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.
  3. Send it on every request as the x-api-key header. 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_id you 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

ScopeGrants
readAll read endpoints (claims, calls, usage, carriers, doctors, offices, upload context).
writeAdds claim upload/update and the provisioning endpoints (offices, doctors, carrier-requests, doctor-carrier links).
calls:writeAdds 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, and POST /api/carrier-requests. It does NOT cover POST /api/uploads/claims (claim upload/update) or POST /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 get 429 with "code":"WRITE_CAP_REACHED" — wait for the daily reset. Distinguish that from 503 WRITE_CAP_UNAVAILABLE (the write-counter backend was briefly unavailable and the request failed closed): a 503 is transient — back off and retry.
  • Daily call cap — a calls:write key has its own per-day ceiling on make-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:

OutcomeResponseWhat 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).
Ambiguous200 { "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 flag200 { "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:true201 { "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: false with a candidates list (empty on a brand-new name) and no id. "create": true is 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:true applies 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 no create:true on 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 explicit resolve_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_request is filed: you get back the request id and status: "pending" (no carrier_id). We provision the carrier, and when it's ready we fire the carrier.request_resolved webhook (see §8) so you can resume automatically. You can also poll GET /api/claims — its response includes a carrier_requests array ({ 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_name is strictly required to insert — but the recommended fields above are what make a claim callable, so send everything your PMS has.
  • insurance_phone is normalized to digits; a matching carrier_id may be resolved from it.
  • Dates are YYYY-MM-DD. billed_amount is 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_status is matched case-insensitively with surrounding whitespace trimmed — Denied, DENIED, and Denied all 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 default Open status (which is dialable), and every ignored value is reported on the upload response in ignored_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 Number is 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_at is set), so call history is never lost. Restore it with POST /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 batch POST /api/uploads/claims above — and it behaves differently. It creates one record, can resolve doctor_name / office_name / carrier_name to ids, and applies create-or-match dedupe (returns the existing claim on a match). Its field allowlist also differs — keyed callers can't set insurance_paid_amount / payment_check_number, and an unknown field returns 400 rather than being silently stripped. Both routes pin organization_id from your key, require a US insurance_phone, and reject a foreign doctor_id / office_id. For imports, use POST /api/uploads/claims (above); reach for POST /api/claims only 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_blockers collects org-state gates that would refuse a real call — VOICE_OFF and HOLIDAY_WEEKEND_FLOOR. Non-empty → status is would_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:write key hits its daily cap, even a dry_run is refused with 429 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 no calls row). 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}/callable returns an overall callable flag plus a dispatch_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.

⚠️ /callable and make-call answer different questions — gate on the right one

These two endpoints are not the same check. GET /api/claims/{id}/callable answers "would the autodialer pick this claim up on its own?" — so it includes pacing gates such as COOLDOWN that exist only to stop the dialer re-calling the same claim. POST /api/make-call answers "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-call dry 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-call still enforces the real gates — VOICE_OFF, a claim missing required fields, no callable phone — so a claim blocked for a real reason is refused by make-call and its dry run too, not only by /callable. /callable is not noise; it is the autodialer's view, and the only difference from make-call is 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

BlockerMeaningWhat to do
MISSING_FIELDSthe claim is missing a field a call needssupply it. The seven fields checked are patient_name, date_of_birth, date_of_service, billed_amount, office_id, doctor_id, carrier_id
STATUS_NOT_DIALABLEclaim_status isn't a dialable valueset an accepted status (§6) via action:"update"
NO_CALLABLE_PHONEno usable insurance_phone, and none resolvable from the carrieradd a valid insurance_phone, or use a carrier we can resolve a number for
PAIDa payment amount is recorded, so we won't chase itexpected on paid claims — nothing to do

2 · Wait — these clear on their own

BlockerClears when
CARRIER_HOURSthe carrier's calling hours begin
HOLIDAY_WEEKEND_FLOORthe next permitted day (see the note below)
COOLDOWNthe cooldown window since the last call elapses

3 · Act — these will not clear by waiting

BlockerMeaningWhat to do
VOICE_OFFcalling is switched off for your whole organizationturn it on — but read "Arming voice" below first: it is org-wide, not per-claim
INTERNAL_TEST_CARRIERthe claim is on the sandbox test carrierexpected 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/claims and 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 null summary right after a call is expected. ai_summary and the extracted denial_reasons are 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 empty ai_summary immediately after the call as a failure — it fills in shortly. Read results from GET /api/claims/{id}/denial-reasons after the call.completed webhook 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/min429 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

EventFires when
call.completedA call finishes. This is the default subscription.
claim.status_changedA 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_resolvedA 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 envelope created_at

Two events for the same claim can carry a byte-identical envelope created_at (verified in production: a Denied then a Paid with the same timestamp). Ordering status writes by created_at can write an older status over a newer one — e.g. reverting a Paid claim to Denied. For claim.status_changed, the data object carries status_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. Order claim.status_changed by data.status_changed_at, not the envelope created_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 2xx within 10 seconds, or it counts as a failed delivery and is retried. Respond 2xx first, 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 2xx immediately → verify signature over the raw body → dedupe on id → 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-settings accepts an ordinary write key (§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.