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:

  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

  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

The three scopes

ScopeGrants
readAll read endpoints (claims, calls, usage, carriers, doctors, offices, upload context).
writeAdds claim upload/update and the create-or-match 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


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

Identifier notes:

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" }'

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"
      }
    ]
  }'

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

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 a claim will or won't be dialed. The make-call dry-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/min429 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

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 }
}

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


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:

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.