Claim Chaser Docs
Claim Chaser API Reference
Access your organization's claim, call, usage, and carrier data — and push claims in — from your own software, scripts, or the Claim Chaser CLI. It's the same data you see in the app, over HTTP.
Overview
- Base URL:
https://app.claimchaser.ai - Auth: an API key sent as the
x-api-keyheader. No password, no browser session required. - Scope: every key is bound to one organization. A key only ever sees and touches that org's data — you never pass an organization id (any
organization_idin the query or body is ignored).
Access model
All keys are self-serve — generate one in the Developer tab of the app and assign the scopes your integration needs at creation:
read— the read endpoints below.write— adds claim upload and update.calls:write— adds triggering calls; you set a per-day call cap (default 50) when you create the key.
The write, call-triggering, and webhook capabilities documented below are fully live — just select the scope you need when you create the key.
Authentication
- In the Developer tab, generate a key — give it a name and select the scopes you need (
read,write, orcalls:write). - The full key (format
cc_live_…) is shown once — copy it immediately and store it somewhere safe. You will not be able to see it again (we only keep a hashed copy). - Send it on every request as the
x-api-keyheader:
curl -H "x-api-key: cc_live_YOUR_KEY_HERE" \
https://app.claimchaser.ai/api/claims
- Revoke a key anytime in the Developer tab — it stops working on the very next request (no caching).
- Keys can be given an expiry; expired keys are rejected.
- Keep keys secret. Anyone with a key can act on your org's data within the key's scopes. Rotate (obtain a new one, revoke the old) if a key may be exposed.
Scopes
A key carries one or more scopes. Assign the least it needs.
| Scope | Grants | How you get it |
|---|---|---|
read | All read endpoints below (claims, calls, usage, carriers, doctors, offices). | Self-serve in the Developer tab. |
write | Adds bulk claim upload (POST /api/uploads/claims). | Self-serve in the Developer tab. |
calls:write | Adds starting claim-chasing calls (POST /api/make-call). Calls cost real money, so every calls-enabled key carries a daily call cap — and dry_run lets you rehearse a dispatch without dialing or billing. | Self-serve in the Developer tab. |
Scopes are cumulative — a write key can also read, and a calls:write key can read and write.
Daily call cap: a calls:write key is minted with a per-day ceiling on how many calls it can start (default 50, max 500, resets at 00:00 UTC). When the cap is reached, make-call returns 429 until the next UTC day. A key's cap is managed from the Developer page and can never be changed by the API key itself.
Endpoints (v1)
All endpoints are org-scoped to your key automatically. Only the exact routes below are reachable by a key; every other route (and method) is denied with a uniform 404 so existence never leaks across orgs.
Reads — require the read scope
Reads return the matching rows for your organization, newest first. There are no pagination parameters in v1. Very large result sets may be subject to a platform row cap — if you expect to pull thousands of rows, reach out; cursor-based pagination is on the roadmap.
| Method | Path | Returns |
|---|---|---|
| GET | /api/claims | Your org's claims (full rows) plus lookup lists — doctors, carriers, offices, callability. |
| GET | /api/claims/denial-reasons?claim_ids=… | Denial reasons for a batch of claim ids (comma-separated). |
| GET | /api/claims/{id}/claim-statuses | Status history for one claim. |
| GET | /api/claims/{id}/denial-reasons | Denial reasons extracted for one claim — the core call output. |
| GET | /api/calls/all | Every call for your org (flattened summary rows). |
| GET | /api/calls/recent?limit=N | Most recent calls (default 10). |
| GET | /api/calls/stats | Call aggregates: active, today, average duration, success rate. |
| GET | /api/calls/{id} | One call in full detail, with its linked claim. |
| GET | /api/carriers/{id}/calls | Calls placed for a given carrier. |
| GET | /api/usage/summary | Usage summary: current month, last 12 months, all time. |
| GET | /api/usage/report?year=YYYY&month=MM | Detailed usage report — year is optional and defaults to the current year (add include_calls=true for per-call rows). |
| GET | /api/organization-carriers | Your org's carrier network, with phone numbers. |
| GET | /api/doctor-carriers?carrier_id=… | Doctor ↔ carrier links for your org. |
| GET | /api/doctors | Doctors in your org (plus offices). |
| GET | /api/offices | Offices in your org. |
| GET | /api/uploads/context | Reference data for building an upload — offices, doctors, carriers, value mappings. |
Writes — require the write scope
| Method | Path | Does |
|---|---|---|
| POST | /api/uploads/claims | Bulk-upload claims into your org ("action":"insert") or patch existing claims ("action":"update") — the primary integration point (push claims from your PMS/software). |
Calls — require the calls:write scope
A key with the calls:write scope can start real claim-chasing calls — see Triggering calls below for the request body, the no-cost dry_run rehearsal, and the full error surface.
| Method | Path | Does |
|---|---|---|
| POST | /api/make-call | Starts the next claim-chasing call for your org (or for a specific claim via {"claim_id":"…"}). Subject to the key's daily call cap, your org's concurrent-call limit, and calling-hours rules. Pass "dry_run": true to verify eligibility without dialing or billing. |
Read examples
A request and a representative response for each read endpoint. Fields marked … are ids/values specific to your org. The /api/claims list returns a fixed partner field set. Other read endpoints may return additional fields beyond those shown in these examples — treat any field not documented here as internal, unstable, and not to be relied on; it may change or be removed without notice.
GET /api/claims
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/claims
Response
{
"claims": [
{
"id": "…",
"claim_number": "CLM-1001",
"patient_name": "Jane Doe",
"carrier_id": "…",
"office_id": "…",
"doctor_id": "…",
"insurance_phone": "8005551234",
"date_of_service": "2026-05-01",
"billed_amount": 420.0,
"claim_status": "Denied",
"created_at": "2026-05-02T14:03:00Z"
}
],
"doctors": [ { "id": "…", "name": "Dr. Smith", "npi": "1234567890" } ],
"carriers": [ { "id": "…", "name": "Acme Health" } ],
"offices": [ { "id": "…", "name": "Main Street Clinic" } ],
"carrier_requests": [ { "id": "…", "requested_name": "New Carrier LLC" } ],
"callability": [ { "claim_id": "…", "callability_status": "callable", "missing_fields": [] } ]
}
The claims row returns the documented partner fields — identity, dates, amounts, claim_status, and the carrier_id / office_id / doctor_id references (plus the subscriber_*, patient_*, and payment fields). Internal pipeline columns and status notes are not returned. The example shows the load-bearing fields.
GET /api/claims/{id}/denial-reasons
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/claims/CLAIM_ID/denial-reasons
Response
{
"success": true,
"denial_reasons": [
{
"id": "…",
"claim_id": "…",
"denial_reason": "Timely filing limit exceeded",
"date_recorded": "2026-05-03T00:00:00+00:00",
"resubmission_instructions": "Refile with proof of timely submission",
"date_reason_resubmitted": null,
"date_accepted": null,
"status": "Pending",
"created_at": "2026-05-03T09:20:00Z",
"updated_at": "2026-05-03T09:20:00Z"
}
]
}
GET /api/claims/denial-reasons?claim_ids=… (batch)
Request
curl -H "x-api-key: cc_live_…" \
"https://app.claimchaser.ai/api/claims/denial-reasons?claim_ids=ID_1,ID_2"
Response
{
"denial_reasons": [
{ "id": "…", "claim_id": "ID_1", "denial_reason": "…", "date_recorded": "2026-05-03T00:00:00+00:00", "status": "Pending" }
]
}
GET /api/claims/{id}/claim-statuses
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/claims/CLAIM_ID/claim-statuses
Response
{
"success": true,
"claim_statuses": [
{
"id": "…",
"status": "Denied",
"call_id": "…",
"status_changed_at": "2026-05-03T09:20:00Z"
}
]
}
GET /api/calls/all
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/calls/all
Response
{
"calls": [
{
"id": "…",
"carrier": "Acme Health",
"numberCalled": "8005551234",
"patient": "Jane Doe",
"claimNumber": "CLM-1001",
"startTime": "2026-05-03T09:00:00Z",
"endTime": "2026-05-03T09:07:00Z",
"createdAt": "2026-05-03T09:00:00Z",
"duration": "7:00",
"status": "completed",
"conversationId": "…",
"successStatus": []
}
]
}
GET /api/calls/recent?limit=N
Request
curl -H "x-api-key: cc_live_…" \
"https://app.claimchaser.ai/api/calls/recent?limit=5"
Response
{
"calls": [
{
"id": "…",
"claim_id": "…",
"call_status": "completed",
"started_at": "2026-05-03T09:00:00Z",
"ended_at": "2026-05-03T09:07:00Z",
"to_number": "8005551234",
"conversation_id": "…",
"duration": "7:00",
"carrier": "Acme Health",
"claim_number": "CLM-1001",
"patient_name": "Jane Doe",
"status": "completed"
}
]
}
GET /api/calls/stats
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/calls/stats
Response
{
"activeCalls": 0,
"today": 3,
"avgDuration": "4:12",
"successRate": 82
}
GET /api/calls/{id}
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/calls/CALL_ID
Response
{
"call": {
"id": "…",
"call_status": "completed",
"started_at": "2026-05-03T09:00:00Z",
"ended_at": "2026-05-03T09:07:00Z",
"to_number": "8005551234",
"ai_summary": null,
"duration": "7:00",
"claims": {
"id": "…",
"organization_id": "…",
"patient_name": "Jane Doe",
"patient_id": "…",
"date_of_birth": "1985-04-12",
"claim_number": "CLM-1001",
"date_of_service": "2026-05-01",
"billed_amount": 420.0,
"claim_status": "Denied"
}
}
}
The call row returns structured fields — status, timing, phone numbers, and the linked claim. Agent-generated summaries (ai_summary) and raw transcripts are gated (returned as null); read the call outcome from the linked claim's claim_status and the denial-reasons endpoint. The example shows the load-bearing fields.
GET /api/carriers/{id}/calls
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/carriers/CARRIER_ID/calls
Response
{
"calls": [
{
"id": "…",
"call_status": "completed",
"claims": { "id": "…", "patient_name": "Jane Doe", "claim_number": "CLM-1001" }
}
]
}
The call row returns structured fields — status, timing, phone numbers, and the linked claim. Agent-generated summaries (ai_summary) and raw transcripts are gated (returned as null); read the call outcome from the linked claim's claim_status and the denial-reasons endpoint. The example shows the load-bearing fields.
GET /api/usage/summary
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/usage/summary
Response
{
"organization_id": "…",
"current_month": {
"year": 2026, "month": 5, "month_name": "May",
"duration_seconds": 5400, "call_count": 12, "duration_formatted": "1h 30m"
},
"last_12_months": {
"duration_seconds": 64800, "call_count": 140, "duration_formatted": "18h 0m",
"monthly_breakdown": [
{ "year": 2026, "month": 5, "month_name": "May",
"total_duration_seconds": 5400, "total_duration_formatted": "1h 30m", "call_count": 12 }
]
},
"all_time": { "duration_seconds": 90000, "call_count": 200, "duration_formatted": "25h 0m" }
}
Durations are formatted as a compact human-readable string (e.g. 1h 30m, 4m 14s, 15s), not H:MM:SS. Use duration_seconds for exact arithmetic.
GET /api/usage/report?year=YYYY&month=MM
Request
curl -H "x-api-key: cc_live_…" \
"https://app.claimchaser.ai/api/usage/report?year=2026&month=5"
Response
{
"organization_id": "…",
"year": 2026,
"month": 5,
"total_duration_seconds": 5400,
"total_duration_formatted": "1h 30m",
"total_call_count": 12,
"monthly_breakdown": [
{ "year": 2026, "month": 5, "total_duration_seconds": 5400, "total_duration_formatted": "1h 30m", "call_count": 12 }
]
// add ?include_calls=true for a "calls": [ … ] array of per-call rows
// year is optional — omit it and the report defaults to the current year
}
GET /api/organization-carriers
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/organization-carriers
Response
{
"organization_carriers": [
{
"id": "…",
"carrier_id": "…",
"active": true,
"inactivation_reason": null,
"inactivated_at": null,
"created_at": "2026-01-10T00:00:00Z",
"updated_at": "2026-01-10T00:00:00Z",
"carrier": {
"id": "…",
"name": "Acme Health",
"claims_phone_number": "8005551234",
"carrier_phone_numbers": [
{ "id": "…", "phone_number": "8005551234", "is_primary": true, "is_active": true }
]
}
}
]
}
GET /api/doctor-carriers?carrier_id=…
Request
curl -H "x-api-key: cc_live_…" \
"https://app.claimchaser.ai/api/doctor-carriers?carrier_id=CARRIER_ID"
Response
{
"doctor_carriers": [
{
"id": "…",
"doctor_id": "…",
"carrier_id": "…",
"active": true,
"inactivation_reason": null,
"inactivated_at": null,
"inactivated_by_kind": null,
"doctor": { "id": "…", "name": "Dr. Smith", "organization_id": "…" }
}
]
}
GET /api/doctors
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/doctors
Response
{
"doctors": [
{ "id": "…", "name": "Dr. Smith", "npi": "1234567890", "office": { "id": "…", "name": "Main Street Clinic" } }
],
"offices": [ { "id": "…", "name": "Main Street Clinic" } ]
}
GET /api/offices
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/offices
Response
{
"offices": [
{ "id": "…", "name": "Main Street Clinic" }
]
}
Full office rows are returned; the example shows the load-bearing fields.
GET /api/uploads/context
Request
curl -H "x-api-key: cc_live_…" \
https://app.claimchaser.ai/api/uploads/context
Response
{
"organization_id": "…",
"offices": [ { "id": "…", "name": "Main Street Clinic", "address": "…", "ein": "…", "callback_number": "…" } ],
"doctors": [ { "id": "…", "name": "Dr. Smith", "npi": "1234567890" } ],
"carriers": [ { "id": "…", "name": "Acme Health", "in_network": true, "turned_off": false, "inactivation_reason": null } ],
"valueMappings": [ { "organization_id": "…", "entity_type": "carrier", "input_value": "ACME", "entity_id": "…" } ]
}
Uploading claims
POST /api/uploads/claims (requires write) takes an action of "insert" and a claims array. Each claim object maps to columns on your claims table. Your organization is bound from the key, so you do not send an organizationId. Use GET /api/uploads/context to resolve valid carrier_id, office_id, and doctor_id values. insurance_phone is normalized to digits, and a matching carrier_id may be resolved from it.
Request
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",
"insurance_phone": "8005551234",
"carrier_id": "…",
"office_id": "…",
"doctor_id": "…",
"date_of_service": "2026-05-01",
"billed_amount": 420.0,
"claim_status": "Denied"
}
]
}'
Response
{
"data": [
{ "id": "…", "claim_number": "CLM-1001", "patient_name": "Jane Doe" /* …inserted claim row… */ }
],
"documentId": null
}
Claim row schema
Only patient_name is required to insert a row — everything else is optional (a missing patient_name surfaces as a 400 database-constraint error). The recommended fields below are what make a claim callable, so include them whenever you have them.
| Field | Format | Notes |
|---|---|---|
patient_name | string | Required — the only field the database itself insists on. |
patient_id / subscriber_id | string | Recommended — carriers need at least one identifier to locate the claim. |
date_of_birth, date_of_service, date_sent_to_insurance, subscriber_dob | date, YYYY-MM-DD | ISO 8601 date strings. date_of_birth and date_of_service are recommended. |
billed_amount | number | A JSON number (e.g. 420.0) — not a currency string. Recommended. |
insurance_phone | string | Normalized to digits; a matching carrier_id may be resolved from it. |
carrier_id, office_id, doctor_id | uuid | Resolve valid ids via GET /api/uploads/context. |
claim_status | string | Accepts the app's status strings — e.g. Open, Pending, Denied, Payment Pending, Paid, No Claim on File, Incorrect Claim Details, Duplicate Claim. |
claim_number, group_number, subscriber_name, patient_zip, patient_phone | string | Optional — include what your PMS has. |
Updating claims — action: "update"
The same endpoint patches existing claims: send "action":"update" and an updates array of { "id": "…", "payload": { … } } objects. Each payload is a partial claim — only the fields you send change. As with inserts, your organization is bound from the key.
An API key may patch exactly 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 in payload — including all financial-amount fields (billed_amount, insurance_paid_amount, patient_responsibility) and organization_id — is silently stripped, not errored: the update still applies the allowed fields, and every stripped key is reported back per-row in stripped so your integration can see exactly what applied. Write access to amount fields may open later.
Request
curl -X POST -H "x-api-key: cc_live_…" \
-H "Content-Type: application/json" \
https://app.claimchaser.ai/api/uploads/claims \
-d '{
"action": "update",
"updates": [
{
"id": "CLAIM_ID",
"payload": {
"claim_status": "Denied",
"insurance_phone": "8005551234",
"billed_amount": 999.0
}
}
]
}'
Response
{
"results": [
{
"id": "CLAIM_ID",
"stripped": ["billed_amount"] // dropped, not applied — the other fields were updated
}
]
}
A row whose payload contains no patchable fields is not written at all — its result carries "error": "No patchable fields in payload." alongside stripped. Per-row database errors (bad id, constraint violations) surface the same way, as an error on that row while the rest of the batch proceeds.
Triggering calls
POST /api/make-call (requires calls:write) starts a claim-chasing call. Send {"claim_id":"…"} to chase a specific claim. Every dispatch counts against the key's daily call cap and is subject to your org's concurrent-call limit and calling-hours rules.
Dry run first: add "dry_run": true and every eligibility gate runs for real (billing, voice on/off, calling hours, claim ownership, carrier and phone resolution, duplicate-call guard) but no call is placed and nothing is billed — you get back the exact dispatch plan.
Org-state gates that would refuse a real call right now are collected into dispatch_blockers instead of failing the rehearsal: possible values are VOICE_OFF and HOLIDAY_WEEKEND_FLOOR, and when the array is non-empty the response's status is would_be_blocked rather than would_dispatch. That means a voice-off org can build and test its integration end-to-end before calling is enabled — a dry run never dials regardless.
Request (dry run)
curl -X POST -H "x-api-key: cc_live_…" \
-H "Content-Type: application/json" \
https://app.claimchaser.ai/api/make-call \
-d '{ "claim_id": "…", "dry_run": true }'
Response (dry run)
{
"success": true,
"dry_run": true,
"status": "would_dispatch",
"dispatch_blockers": [], // empty = a real call would dispatch right now
"message": "Dry run: eligibility verified; no call was placed and nothing was billed.",
"plan": {
"claim_id": "…",
"organization_id": "…",
"carrier_id": "…",
"carrier_name": "Acme Health",
"to_number": "8005551234",
"from_number": "…"
}
}
Response (dry run, org gates would block a real call)
{
"success": true,
"dry_run": true,
"status": "would_be_blocked",
"dispatch_blockers": ["VOICE_OFF"], // and/or "HOLIDAY_WEEKEND_FLOOR"
"message": "Dry run: eligibility verified; a real call would currently be blocked by: VOICE_OFF. No call was placed and nothing was billed.",
"plan": { /* same shape as above */ }
}
Dry-run behavior depends on the claim and org state. The would_dispatch / would_be_blocked shape above assumes an eligible claim on a voice-enabled org. You may also get: 422 {"error":"Claim is missing required fields for a call","missing":[...]} when the claim lacks data (e.g. date_of_birth); 202 {"suppressed":true,"reason":"carrier_hours"} when the carrier is outside its calling hours; or a 403 voice-off error when you call make-call without a claim_id on a voice-off org. Target a fully-populated, specific claim_id to exercise the documented rehearsal.
Request (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": "…" }'
Response
{
"success": true,
"status": "initiated",
"call_id": "…", // correlate the later call.completed webhook, or poll GET /api/calls/{call_id}
"claim_id": "…",
"conversation_id": "…"
}
Call-specific errors
| Status | Body | When |
|---|---|---|
| 403 | {"error":"API key is missing the 'calls:write' scope required to start calls."} | The key lacks calls:write. |
| 429 | {"error":"…","code":"API_KEY_DAILY_CAP_REACHED","daily_call_cap":50,"used_today":50} | The key hit its daily call cap. Resets at 00:00 UTC. |
| 429 | {"error":"Rate limit exceeded. Please try again later.","code":"RATE_LIMIT_EXCEEDED","retryAfter":42} | Per-org rate limit on make-call: 30 requests per minute per organization (dry runs count too). The response carries a Retry-After header (seconds) — wait at least that long before retrying, and back off exponentially if you keep hitting it. |
| 403 | {"error":"…","code":"VOICE_OFF"} | Voice is turned off for your organization — keys never bypass the off-switch. |
| 402 | {"error":"…","code":"ACCOUNT_SUSPENDED" | "ACCOUNT_CANCELLED"} | Billing-suspended or cancelled orgs cannot start calls — recover from the Billing page. |
| 202 | {"suppressed":true,"reason":"holiday_weekend_floor"} | Dispatch suppressed (US federal holiday / weekend floor, or carrier out of business hours with "reason":"carrier_hours"). Not an error and not billed — retry on the next business day. |
Outbound webhooks
Instead of polling, register an HTTPS endpoint on the Developer page and we POST you an event when a call completes (call.completed) or a claim moves to a new status (claim.status_changed). URLs must be https. Each endpoint gets a signing secret, shown once at registration (rotate it on the Developer page if it leaks). Registration and the signing secret are set up in the Developer page, not via the API — there is no key-reachable endpoint to create an endpoint or fetch its secret.
What you're subscribed to
- Default subscription =
call.completedonly. To receiveclaim.status_changedyou must explicitly include it inevent_typeswhen you register the endpoint (or add it later by editing the endpoint on the Developer page).event_typesmust be a non-empty array drawn fromcall.completedandclaim.status_changed; omit it entirely to get the default. claim.status_changedis deliberately sparse: it fires only when a claim transitions into one of these 6 statuses —Denied,Paid,Payment Pending,No Claim on File,Incorrect Claim Details,Duplicate Claim— never on every status write. Poll the read API if you need statuses outside that set.
Event envelope
Every delivery is a JSON envelope. The payload deliberately carries IDs and status only — no PHI rides in the webhook; fetch the full result over the read API (below).
{
"id": "call.completed:<call_id>", // unique per event — dedupe on this
"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",
"started_at": "2026-07-13T17:58:02Z",
"ended_at": "2026-07-13T18:04:05Z",
"duration_seconds": 363
}
}
A claim.status_changed event carries this data:
{
"claim_id": "…",
"status": "Denied",
"status_changed_at": "2026-07-13T18:04:11Z",
"call_id": "…"
}
Verifying signatures
Every delivery carries a Claim-Chaser-Signature header:
Claim-Chaser-Signature: t=<unix-seconds>,v0=<hex>
where v0 is HMAC_SHA256(secret, ${t}.${rawBody} ) over the raw request body (before any JSON parsing). Reject deliveries whose timestamp is more than 30 minutes from now — that bounds replay.
// Node.js — verify a Claim Chaser webhook (raw body required)
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 // stale or malformed timestamp — possible replay
}
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)
}
// Express example — capture the RAW body, not the parsed JSON:
// app.post('/webhooks/claim-chaser', express.raw({ type: 'application/json' }), (req, res) => {
// res.sendStatus(200) // ack immediately, then process
// if (!verifyClaimChaserSignature(req.body.toString('utf8'),
// req.get('Claim-Chaser-Signature') || '', process.env.CC_WEBHOOK_SECRET)) return
// const event = JSON.parse(req.body.toString('utf8'))
// // dedupe on event.id, then act on event.type / event.data
// })
Delivery contract
- At-least-once: the same event can arrive more than once — dedupe on the envelope
id. - No ordering guarantee: events can arrive out of order — treat the read API as the source of truth and reconcile against it.
- Retries: failed deliveries are retried with exponential backoff for about 24 hours (roughly 6 attempts at 1m, 5m, 30m, 2h, 6h, 16h), then marked
dead— visible in the delivery log on the Developer page, where you can resend them. - 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.
Recommended flow
- Respond
2xximmediately — do the work after acknowledging, not before. - Verify the signature over the raw body.
- Dedupe on the envelope
id. - Fetch the full result:
GET /api/claims/{claim_id}/denial-reasons— the webhook payload deliberately carries IDs and status only.
Using the CLI with a key
The Claim Chaser CLI can authenticate with a key instead of email/password — and with a key you don't need any local database configuration on your machine. Set CLAIM_CHASER_API_KEY and the base URL defaults to https://app.claimchaser.ai.
export CLAIM_CHASER_API_KEY="cc_live_YOUR_KEY_HERE"
claim-chaser whoami # shows your org (keyed: true)
claim-chaser usage summary
claim-chaser claims get <claim-id>
Read commands and claim upload work under a key, matching the endpoint allowlist above. Commands that dial (make-call) need a key with the calls:write scope and count against that key's daily call cap.
Not available via the API (Developer-page only)
Some operations are deliberately kept out of the keyed surface. If your integration needs any of these, a person does it in the app:
- Pausing or resuming voice (the org-wide calling on/off switch and schedule).
- Editing the carrier catalog or carrier phone numbers.
- Turning a carrier on or off for your org.
- Creating doctors or offices (reads exist; creation is in-app only).
- API-key management — minting, revoking, or changing a key's daily call cap.
- Webhook-endpoint management — registering, editing, rotating secrets, or deleting endpoints.
Errors
| Status | Body | When |
|---|---|---|
| 401 | {"error":"Unauthorized"} | Missing, malformed, revoked, or expired key. |
| 403 | {"error":"API key is missing the 'write' scope required for POST /api/uploads/claims."} | The route is allowlisted but your key lacks the required scope. The message names the missing scope and route. |
| 404 | (empty body) | The route/method is not reachable by a key (not on the allowlist). Uniform and existence-neutral — it never reveals whether a route exists or that a resource belongs to another org. |
| 400 | {"error":"…"} | Validation error on a write — e.g. {"error":"Invalid action"}, or a database constraint message surfaced from the claim insert. |
| 429 | {"error":"This API key has reached its daily call cap. Try again after 00:00 UTC.","code":"API_KEY_DAILY_CAP_REACHED","daily_call_cap":50,"used_today":50} | A calls:write key hit its daily call cap on make-call. Resets at 00:00 UTC. (make-call is also rate-limited to 30 requests/min per org — "code":"RATE_LIMIT_EXCEEDED" with a Retry-After header; see the call-specific errors above.) |
Security notes
- Keys are stored hashed (SHA-256) — we never store or log the raw key.
- A key authenticates an organization, not a user; it can only reach the exact allowlisted routes above, scoped to your org.
- Revocation is immediate — the key lookup is uncached and re-checks revoked/expired state on every request.
- Admin, billing, user-management, cron, and internal/debug routes are never reachable by a key.