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.
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
- 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 create-or-match 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 — the create/upload endpoints are gated by a per-key daily write counter (default 300/day). Exceed it and you get
429with"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 / you forced create | { "created": true, "id": "…" } | Nothing matched, or you sent create:true. Validated, then inserted, org bound from the key. |
Two levers you send to resolve an ambiguous result:
"create": true— override: create a new record even though candidates exist."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"
}'
Match response:
{ "matched": true, "id": "OFFICE_ID", "match_type": "name" }
Created response:
{ "created": true, "id": "OFFICE_ID" }
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"
}'
Ambiguous response — you 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.
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.
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.
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 a claim will or won't be dialed. Themake-calldry-run above remains available as an alternative that exercises the same gates without placing a call.
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}.
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.
8 · Webhooks — don't poll
Register an HTTPS endpoint on the Developer page (not via the API). You get a signing secret shown once. 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 }
}
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 — treat the read API as source of truth and reconcile.
- Retries — failed deliveries retry with exponential backoff for ~24h (~6 attempts), then
dead(resend from the delivery log). - 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.)
- Voice on/off — the org-wide calling switch and schedule.
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 only carrier catalog creation, API-key and webhook-endpoint management, and the voice switch.
10 · The full journey, end to end
# 0. one-time: export your key
export KEY="cc_live_YOUR_KEY_HERE"; export BASE="https://app.claimchaser.ai"
# 1. office (first — doctors reference it)
curl -sX POST -H "x-api-key: $KEY" -H "Content-Type: application/json" $BASE/api/offices \
-d '{"name":"Main Street Clinic","address":"123 Main St","callback_number":"2085551234","ein":"123456789"}'
# 2. doctor (link to the office_id from step 1)
curl -sX POST -H "x-api-key: $KEY" -H "Content-Type: application/json" $BASE/api/doctors \
-d '{"name":"Dr. Smith","npi":"1234567890","office_id":"OFFICE_ID"}'
# 3. carrier — match-first; use returned carrier_id, or wait for carrier.request_resolved
curl -sX POST -H "x-api-key: $KEY" -H "Content-Type: application/json" $BASE/api/carrier-requests \
-d '{"requested_name":"Acme Health"}'
# 4. credential the pair
curl -sX POST -H "x-api-key: $KEY" -H "Content-Type: application/json" $BASE/api/doctor-carriers/turn-on \
-d '{"doctor_id":"DOC_ID","carrier_id":"CARRIER_ID"}'
# 5. upload the claim
curl -sX POST -H "x-api-key: $KEY" -H "Content-Type: application/json" $BASE/api/uploads/claims \
-d '{"action":"insert","claims":[{"patient_name":"Jane Doe","subscriber_id":"W123","carrier_id":"CARRIER_ID","office_id":"OFFICE_ID","doctor_id":"DOC_ID","date_of_birth":"1985-04-12","date_of_service":"2026-05-01","claim_status":"Denied"}]}'
# 6. confirm callable (no dial, no bill)
curl -sX POST -H "x-api-key: $KEY" -H "Content-Type: application/json" $BASE/api/make-call \
-d '{"claim_id":"CLAIM_ID","dry_run":true}'
Zero browser steps — carrier provisioning aside. For every exact field, status code, and error body, the API Reference is the contract.