REST API
The generated API Reference is the always-fresh source
This page is a hand-written narrative of the /v1 surface as of core v0.9, kept for its guidance and context. For the complete, guaranteed-current endpoint reference — generated at every build from core's drift-gated OpenAPI spec (v0.12.x, including SMS, DIDs, carriers and inbound routes) — use the API Reference.
Reference for the core control-plane API (/v1), as implemented in core v0.9 (the billing & account surface). All endpoints except GET /healthz require an API key — see Authentication.
Conventions (error envelope, request IDs, tenancy, timestamps) are described in the Developers overview.
To exercise the surface instead of reading it, import the Postman collection — 27 folders over /v1, vendored from the integration harness with the local credential stripped.
Endpoint index
| Method | Path | Purpose |
|---|---|---|
GET | /healthz | Liveness probe (unauthenticated). |
GET | /v1/me | The authenticated tenant (incl. tier, voice_ai_enabled). |
PATCH | /v1/tenant | Account self-edit. name ONLY — plan, seats and the add-on are platform surface. |
GET | /v1/tenants | One-element list: your own tenant. |
GET | /v1/tenants/{id} | Your tenant, or 404. |
GET | /v1/api_keys | List API keys. |
POST | /v1/api_keys | Mint a key (plaintext shown once). |
DELETE | /v1/api_keys/{id} | Revoke a key. |
GET | /v1/campaigns | List campaigns. |
POST | /v1/campaigns | Create a campaign (state draft). |
GET | /v1/campaigns/{id} | Get one campaign. |
PATCH | /v1/campaigns/{id} | Update config (never state). |
POST | /v1/campaigns/{id}/start | running + spawn the dial-loop runner. |
POST | /v1/campaigns/{id}/resume | Alias of start. |
POST | /v1/campaigns/{id}/pause | paused, stop the runner. |
POST | /v1/campaigns/{id}/stop | completed (terminal). |
POST | /v1/campaigns/{id}/archive | draft|completed → archived. |
GET | /v1/campaigns/{id}/stats | Live runner + pacing counters. |
GET/POST | /v1/campaigns/{id}/rewind/preview | Rewind dry-run. |
POST | /v1/campaigns/{id}/rewind | Re-queue matching debts. |
POST | /v1/debts/import | Bulk JSON lead import (idempotent). |
GET | /v1/debts/{id} | Debt + contacts. |
GET | /v1/debts/{id}/call_attempts | Full durable CDR history of one debt. |
GET | /v1/debts/{id}/defense-packet | Sealed litigation evidence bundle (JSON or ZIP). |
GET | /v1/call_attempts | Filtered, keyset-paginated CDR query. |
POST | /v1/call_attempts/{id}/disposition | Typed agent/AI outcome (+ promise for PTP). |
GET | /v1/promises | List promises. |
PATCH | /v1/promises/{id} | pending → kept|broken|cancelled. |
POST | /v1/callbacks | Schedule a consumer-agreed redial. |
GET | /v1/callbacks | Queue view / runner due feed. |
POST | /v1/callbacks/{id}/complete | pending → done. |
POST | /v1/callbacks/{id}/cancel | pending → cancelled. |
POST | /v1/callbacks/{id}/miss | pending → missed. |
GET | /v1/agents | Roster + live presence. |
POST | /v1/agents | Create an agent seat. |
GET | /v1/agents/{id} | One agent + presence. |
POST | /v1/agents/checkin | Seat goes available (by sip_extension; optional device_mode). |
POST | /v1/agents/checkout | Seat goes offline. |
POST | /v1/supervision/calls/{call_id}/listen | Silent monitor a live call. |
POST | /v1/supervision/calls/{call_id}/whisper | Coach the agent (audible to the agent only). |
POST | /v1/supervision/calls/{call_id}/barge | Three-way (both sides hear the supervisor). |
POST | /v1/supervision/calls/{call_id}/takeover | Transfer the debtor to the supervisor. |
GET | /v1/dids | DID registry (filter by status). |
POST | /v1/dids | Register a tenant-owned number. |
GET | /v1/dids/{id} | One DID. |
PATCH | /v1/dids/{id} | Metadata/status (e164 immutable). |
DELETE | /v1/dids/{id} | Retire (rows never delete). |
GET | /v1/dids/{id}/health | CDR-computed answer/short-call health. |
GET | /v1/stats/summary | Today's operational counters. |
GET | /v1/stats/promises | PTP pipeline summary. |
GET | /v1/stats/penetration | Person-level penetration analytics. |
GET | /v1/stats/blocked | Durable blocked-dial rollup (by-gate + daily). |
GET | /v1/stats/ai-usage | Metered voice-AI minutes (JSON or CSV). |
GET | /v1/reports/violations-prevented | Packaged compliance artifact (JSON or CSV). |
GET | /v1/billing/summary | Invoiceable line items + total (JSON or CSV). |
Plus GET /v1/ws — the WebSocket live floor.
Health
GET /healthz
Unauthenticated liveness probe.
{ "status": "ok", "service": "dialer-core", "version": "0.3.0" }Identity
GET /v1/me
The authenticated tenant (there are no users on this API):
{
"tenant": {
"id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"name": "local-dev",
"status": "active",
"retention_months": 39,
"seats": 5,
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-01T09:00:00Z"
}
}GET /v1/tenants / GET /v1/tenants/{id}
Dashboard-parity endpoints. Since API keys are tenant-scoped, the list always has exactly one element (your tenant), and {id} returns your tenant or 404 not_found — never a 403, never an existence leak.
Campaigns
Campaign lifecycle: draft → running ⇄ paused → completed (terminal) → archived. draft can also be archived directly. Lifecycle is explicit — state is not an accepted field of PATCH /v1/campaigns/{id}, so sending it fails the whole request with 400 bad_request naming the key; use the lifecycle endpoints.
GET /v1/campaigns
{
"campaigns": [
{
"id": "c1a2b3c4-d5e6-7f80-91a2-b3c4d5e6f708",
"tenant_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"name": "local-demo",
"state": "draft",
"abandonment_threshold": "0.03",
"min_dial_ratio": "1.0",
"max_dial_ratio": "3.0",
"calling_window_start": "08:00:00",
"calling_window_end": "21:00:00",
"default_timezone": "America/Chicago",
"record_calls": false,
"ai_voice": false,
"carrier": null,
"caller_ids": ["+13125550199"],
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-01T09:00:00Z"
}
]
}Decimal fields (abandonment_threshold, dial ratios) serialize as strings to avoid float drift.
POST /v1/campaigns
All fields optional except name:
{
"name": "june-chargeoffs",
"abandonment_threshold": 0.03,
"min_dial_ratio": 1.0,
"max_dial_ratio": 2.8,
"calling_window_start": "08:00:00",
"calling_window_end": "21:00:00",
"default_timezone": "America/Chicago",
"record_calls": false,
"ai_voice": false,
"carrier": "your-byoc-trunk",
"caller_ids": ["+13125550199"]
}201 {"campaign": {...}} — new campaigns start in draft. Duplicate name → 409 conflict.
GET /v1/campaigns/{id} / PATCH /v1/campaigns/{id}
Fetch / update the config fields above. PATCH returns 200 {"campaign": {...}}; state is not an accepted field — sending it fails the request with 400 bad_request naming the key. Use the lifecycle endpoints below.
Lifecycle: start / resume / pause / stop / archive
POST /v1/campaigns/{id}/start
POST /v1/campaigns/{id}/resume # alias of start (paused -> running)
POST /v1/campaigns/{id}/pause
POST /v1/campaigns/{id}/stop
POST /v1/campaigns/{id}/archivestartpersistsrunningFIRST, then spawns the dial-loop runner. Idempotent if already running. Optional runner opts in the body, validated fail-closed (400 bad_requeston bad types):json{ "tick_ms": 1000, "batch_size": 10, "gateway": "default", "agent_domain": "agents.local", "call_ttl_ms": 60000, "blocked_cooldown_ms": 300000 }ai_disclosure_configuredya no se acepta aquí: el schema es cerrado y enviarlo es400 bad_request. La divulgación de voz artificial de la vía NO-IA se almacena una vez conPUT /v1/artificial-voice-disclosurey se resuelve por intento, en vez de afirmarse en el arranque.pausepersistspausedand stops the runner; in-flight calls finish via the CDR pipeline.stoppersistscompleted(terminal) and stops the runner.archiveonly fromdraftorcompleted.
Invalid transitions → 409 conflict. All lifecycle responses: 200 {"campaign": {...}}.
GET /v1/campaigns/{id}/stats
Live runner counters; stats is null when the runner is not running:
{
"campaign_id": "c1a2b3c4-d5e6-7f80-91a2-b3c4d5e6f708",
"state": "running",
"running": true,
"stats": {
"dialed": 412,
"blocked": 37,
"finished": 395,
"reaped": 0,
"in_flight": 17,
"pacing": {
"ratio": 2.4,
"abandoned": 9,
"connected": 301,
"abandonment_rate": 0.029,
"tokens": 6
}
}
}The same payload is pushed every ~2 s over the WebSocket as campaign.stats.
Campaign rewind (operator re-queue)
Re-queues exhausted leads so the runner treats them as never-attempted. The CDR is untouched — append-only, Reg F counters keep counting.
Preview (dry-run)
POST /v1/campaigns/{id}/rewind/preview # filters in the JSON body
GET /v1/campaigns/{id}/rewind/preview?dispositions=no_answer,busy&max_attempts=3&last_attempt_older_than_days=7{
"matching": 184,
"excluded_by_compliance": 12,
"debt_ids": ["..."]
}excluded_by_compliance = matching debts the engine would block right now (Reg F / DNC / quiet hours). It is informational — the gates re-enforce at every originate anyway.
Execute
POST /v1/campaigns/{id}/rewind{
"dispositions": ["no_answer", "busy"],
"max_attempts": 3,
"last_attempt_older_than_days": 7,
"keep_scheduled_callbacks": true
}{ "requeued": 172, "callbacks_cancelled": 0 }Rules:
- Filters:
dispositions(matched against the debt's latest disposition),max_attempts(count of original attempts),last_attempt_older_than_days. keep_scheduled_callbacksdefaults totrue;falsecancels the campaign's pending callbacks for the rewound debts.- Sets a per-debt
requeued_atwatermark and resurrectsclosed→open. Debts inpaid/settled/disputedare never rewound. amd_verdictsis reserved →422 unprocessable(fail-closed, never silently ignored). Bad filter types →400.
Debts / leads
POST /v1/debts/import
Bulk JSON import: idempotent, per-item fail-soft, max 10 000 items per request. Debts upsert on external_ref, contacts upsert on phone_e164.
{
"debts": [
{
"external_ref": "demo-1001",
"consumer_ref": "consumer-1001",
"debt_type": "other",
"account_number": "4111-XXXX",
"amount_cents": 50000,
"currency": "USD",
"campaign_id": "c1a2b3c4-d5e6-7f80-91a2-b3c4d5e6f708",
"contacts": [
{
"phone_e164": "+13125551001",
"line_type": "landline",
"timezone": "America/Chicago",
"us_state": "IL",
"city": "Chicago",
"is_primary": true
}
]
}
]
}{ "imported": 1, "failed": [] }external_ref(required) is the Reg F counter key — frequency limits count per debt, so get this stable and unique per account.consumer_ref(required) is the person key — penetration analytics dedupes on it.phone_e164must be E.164 (enforced by a database CHECK);line_type∈mobile|landline|voip|unknown.debt_type∈other|student_loan.- Failures come back per item:
{"failed": [{"index": 3, "error": "..."}]}— the rest of the batch still imports. - CSV multipart upload is not implemented — the dashboard's CSV mapper converts client-side and posts this JSON.
GET /v1/debts/{id}
{
"debt": {
"id": "d0e1f2a3-...",
"tenant_id": "0a1b2c3d-...",
"campaign_id": "c1a2b3c4-...",
"external_ref": "demo-1001",
"consumer_ref": "consumer-1001",
"debt_type": "other",
"account_number": null,
"amount_cents": 50000,
"currency": "USD",
"state": "open",
"last_conversation_at": null,
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-01T09:00:00Z",
"contacts": [
{
"id": "e1f2a3b4-...",
"debt_id": "d0e1f2a3-...",
"consumer_ref": "consumer-1001",
"phone_e164": "+13125551001",
"line_type": "landline",
"timezone": "America/Chicago",
"us_state": "IL",
"city": "Chicago",
"is_primary": true
}
]
}
}GET /v1/debts/{id}/call_attempts
The full durable history of one debt — original attempts plus compensating corrections, in one list. Shape per row as in CDR query.
CDR query
GET /v1/call_attempts
The append-only call-attempt table is the platform's CDR truth.
Query parameters:
| Param | Type | Notes |
|---|---|---|
campaign_id, debt_id, agent_id | UUID | Filters. Non-UUID values → 400. |
disposition | string | answered_human | abandoned | busy | no_answer | canceled | rejected | failed. |
from, to | RFC 3339 | Bounds on started_at. |
limit | int | Default 100, max 1000. |
cursor | string | Opaque keyset cursor from the previous page. |
{
"call_attempts": [
{
"id": "a1b2c3d4-...",
"tenant_id": "0a1b2c3d-...",
"debt_id": "d0e1f2a3-...",
"campaign_id": "c1a2b3c4-...",
"agent_id": "f0e1d2c3-...",
"consumer_ref": "consumer-1001",
"debt_key": "debt:demo-1001",
"call_uuid": "b7c8d9e0-...",
"from_number": "+13125550199",
"to_number": "+13125551001",
"started_at": "2026-06-11T14:29:31Z",
"answered_at": "2026-06-11T14:29:42Z",
"ended_at": "2026-06-11T14:33:05Z",
"disposition": "answered_human",
"hangup_cause": "NORMAL_CLEARING",
"sip_response_code": 200,
"compliance_snapshot": { "...": "frozen decision evidence" },
"corrects_id": null,
"inserted_at": "2026-06-11T14:33:05Z"
}
],
"next_cursor": "g3QAAAAC..."
}Semantics:
- Rows are append-only. Originals have
corrects_id: null; corrections (typed dispositions) point at the original. The latest correction is the final disposition of an attempt. compliance_snapshotcarries the frozen evidence of the compliance decision that allowed the dial — your audit trail, immune to later data changes.
Pagination: the keyset pattern
List endpoints that can grow unbounded (/v1/call_attempts) use keyset cursors, not offsets:
- Request with
limit(≤ 1000). The response includesnext_cursorwhen the page was full,nullwhen you reached the end. - Pass
cursor=<next_cursor>(verbatim, it is opaque) with the same filters to get the next page. - Repeat until
next_cursorisnull.
curl -s "$API/v1/call_attempts?campaign_id=$CID&limit=500" -H "Authorization: Bearer $TOKEN"
curl -s "$API/v1/call_attempts?campaign_id=$CID&limit=500&cursor=$NEXT" -H "Authorization: Bearer $TOKEN"Keyset paging is stable under concurrent inserts — you never skip or double-read rows because new calls landed while you paged. Smaller list endpoints (promises, callbacks) take limit (max 1000) without a cursor for now.
Typed dispositions
POST /v1/call_attempts/{id}/disposition
Records an agent/AI-entered outcome for a finished attempt as a compensating CDR record — the table stays append-only; nothing is ever overwritten.
{id} must be an original row (corrects_id: null); corrections and unknown ids are 404.
{
"disposition": "promise_to_pay",
"agent_id": "f0e1d2c3-...",
"promise": {
"amount_cents": 25000,
"currency": "USD",
"promised_date": "2026-06-21",
"note": "weekly installment agreed on the call"
}
}201:
{
"call_attempt": { "...": "the correction row, corrects_id = the original id" },
"promise": {
"id": "9a8b7c6d-...",
"tenant_id": "0a1b2c3d-...",
"debt_id": "d0e1f2a3-...",
"call_attempt_id": "a1b2c3d4-...",
"agent_id": "f0e1d2c3-...",
"amount_cents": 25000,
"currency": "USD",
"promised_date": "2026-06-21",
"status": "pending",
"note": "weekly installment agreed on the call",
"created_at": "2026-06-11T14:34:00Z",
"updated_at": "2026-06-11T14:34:00Z"
}
}The promise-to-pay contract (422s)
The typed contract is bidirectional and fail-closed:
| You send | Result |
|---|---|
"disposition": "promise_to_pay" with a valid promise payload | 201 — correction + promise commit in one transaction; the debt's lead state advances to promise_to_pay (still dialable). |
"disposition": "promise_to_pay" without promise | 422 unprocessable — a PTP without terms is not a PTP. |
Any other disposition with a promise payload | 422 unprocessable — promises only attach to promise_to_pay. |
Promise payload rules: amount_cents > 0; currency ∈ MXN | USD; promised_date must be in the future, max 90 days out. disposition is any non-empty string; agent_id is optional and must be a UUID (400 otherwise).
Promises (PTP pipeline)
GET /v1/promises
Query params: debt_id (UUID), status (pending|kept|broken|cancelled), limit (default 100, max 1000). Newest first.
{ "promises": [ { "id": "9a8b7c6d-...", "status": "pending", "amount_cents": 25000, "currency": "USD", "promised_date": "2026-06-21", "...": "..." } ] }PATCH /v1/promises/{id}
{ "status": "kept" }Status machine: pending → kept | broken | cancelled — all terminal. Re-transitioning a promise that already reached a terminal state → 409 conflict; any other string → 400 bad_request. Rows are never deleted. Returns 200 {"promise": {...}}.
Callbacks (consumer-agreed redials)
POST /v1/callbacks
{
"debt_id": "d0e1f2a3-...",
"contact_phone": "+13125551002",
"scheduled_at": "2026-06-12T16:30:00Z",
"campaign_id": "c1a2b3c4-...",
"agent_id": "f0e1d2c3-...",
"note": "consumer asked to be called back after lunch",
"priority": 1
}201 {"callback": {...}}. Required: debt_id, contact_phone, scheduled_at. Rules:
contact_phonemust already be a contact of the debt — the compliance gates need its line/locale data →422 unprocessableotherwise.scheduled_atmust be ≥ 10 minutes in the future →422otherwise; non-RFC-3339 →400.- Unknown debt →
404.campaign_iddefaults to the debt's campaign.
GET /v1/callbacks
Query params: due=now|all, campaign_id, debt_id, status (pending|done|cancelled|missed, default pending), limit.
due=now— the runner-facing due feed: pending ANDscheduled_at <= now, ordered by priority then time. The campaign runner dials due callbacks before fresh leads and completes them itself once the attempt row is durable.- Otherwise — the queue view filtered by
status, soonest first.
POST /v1/callbacks/{id}/complete|cancel|miss
Status machine: pending → done | cancelled | missed (terminal). Re-transition → 409 conflict. Returns 200 {"callback": {...}}.
Agents (seats)
Agent presence states: offline | available | ringing | on_call. Presence is driven by checkin/checkout and the dialer itself — there is no presence-PATCH endpoint. (A wrap_up state does not exist yet; see Roadmap.)
GET /v1/agents
Durable roster merged with live presence:
{
"agents": [
{
"id": "f0e1d2c3-...",
"tenant_id": "0a1b2c3d-...",
"name": "Local Dev Agent",
"email": "[email protected]",
"role": "agent",
"status": "active",
"sip_extension": "1000",
"presence": "available",
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-01T09:00:00Z"
}
]
}status is the roster active/inactive flag; presence is the live seat state.
POST /v1/agents
{ "name": "Maria Lopez", "email": "[email protected]", "role": "agent", "sip_extension": "1001" }201 {"agent": {...}}. name and email required; role ∈ agent|supervisor|admin; sip_extension is the SIP registration identity calls bridge to.
GET /v1/agents/{id}
One agent + live presence, or 404.
POST /v1/agents/checkin / POST /v1/agents/checkout
{ "sip_extension": "1000" }- checkin — seat goes
available.404unknown/inactive extension;409 conflictif the seat is on a call. Optionaldevice_mode(browser | external) persists how this seat is bridged —browserseats are hunted at the WSS softphone first,externalseats (Zoiper / desk phone) go straight to the registrar; omitted keeps the stored value, anything else →422. - checkout — only an
availableseat may leave (409otherwise — fail-closed: you cannot pull a seat out from under a live call).
Both return 200 {"agent": {...}} (the agent JSON carries device_mode). Presence transitions also stream as agent.presence WebSocket events.
Supervision (live-call monitoring)
POST /v1/supervision/calls/{call_id}/{listen|whisper|barge|takeover} — body {"supervisor_ext": "2000"}. {call_id} is the live call's FreeSWITCH uuid (= call_attempts.call_uuid, also the call_id of the call.* floor events).
- listen — hear both sides, audible to nobody.
- whisper — audible to the AGENT leg only (coaching).
- barge — three-way (both sides hear the supervisor).
- takeover — transfer the debtor leg to the supervisor and drop the agent.
listen|whisper|barge ring the supervisor's REGISTERED endpoint (their checked-in softphone or SIP device — answer within 20 s to join). One active mode per supervisor per call: switching mode is a new call (the previous leg hangs up, its audit row flips to ended, a supervision.ended event fires).
200 {"supervision": {"action": "listen", "call_id": "...",
"supervisor_ext": "2000", "session_id": "<leg uuid>",
"audit_id": "<row>", "to_number": "***0184"}}session_id is null for takeover; to_number is masked last-4 (the full E.164 never crosses this surface). Evidence-first: every attempt — success OR failure, including probes at unknown uuids — writes a durable supervision_actions row BEFORE any switch command; a supervision.started event broadcasts on success. Errors: 400 missing supervisor_ext; 404 unknown supervisor extension or no such call in this tenant (cross-tenant uuids are indistinguishable from nonexistent and trigger ZERO commands); 503 audit_unavailable (the evidence row could not be written, so nothing was sent); 503 esl_unavailable (switch link down, retry); 502 supervision_failed (call already ended / supervisor device not registered).
Caller ID & DIDs
The registry of the tenant-owned numbers campaigns dial from, plus CDR-computed health.
GET /v1/dids?status=active|quarantine|retired→{"dids": [...]}(ordered bye164; bad status →400).POST /v1/dids→201 {"did": {...}}. Fields:e164(required, E.164, unique per tenant →409on duplicates),npa(derived from+1numbers when omitted),us_state,attestation(A|B|C|unknown— the STIR/SHAKEN level the carrier signs),status(defaultactive),labels(string array),notes.GET /v1/dids/{id}→ one DID.PATCH /v1/dids/{id}— metadata/status only;e164is immutable (a different number is a different DID; CDR history is never re-attributed).DELETE /v1/dids/{id}— retires (statusretired), idempotent. Rows never leave the registry: CDRfrom_numberprovenance must keep resolving for the Defense Packet.GET /v1/dids/{id}/health?from&to(RFC-3339 overstarted_at, default trailing 30 UTC days) → answer rate and short-call rate computed from the append-only CDR:json{"did_id": "...", "e164": "+13125550184", "status": "active", "period": {"from": "...", "to": "..."}, "dials": 412, "answered": 67, "answer_rate": 0.1626, "short_calls": 24, "short_call_rate": 0.3582, "short_call_seconds": 15, "daily": [{"date": "2026-06-01", "dials": 80, "answered": 14, "short_calls": 5}], "reputation": {"hiya": null, "tns": null, "note": "external feeds not integrated yet"}}short_call_rate(answered calls ending under 15 s) is the strongest CDR-side "spam likely" signal — pick up, see the label, hang up. Rates arenullwhen the denominator is zero (never invented);reputation.*are explicitnulluntil an external feed is contracted.
Stats
GET /v1/stats/summary
Today's operational counters — computed from the append-only CDR (UTC day window) + the live seat registry. Reconstructible truth, not cached state.
{
"date": "2026-06-11",
"window": "utc_day",
"calls_today": 412,
"connected_today": 301,
"connect_rate": 0.7306,
"agents_online": 4,
"agents_available": 2,
"campaigns_running": 1
}GET /v1/stats/promises
PTP pipeline summary:
{
"open": { "count": 14, "amount_cents": { "USD": 412500, "MXN": 0 } },
"kept_rate_30d": 0.62,
"window_days": 30
}- Currencies never sum together.
kept_rate_30dis computed over promises already settled — kept or broken — withpromised_datein the last 30 days;nullmeans none have settled yet.
GET /v1/stats/penetration
Person-level penetration analytics. Query params: campaign_ids=a,b (comma-separated UUIDs; campaign_id= also accepted), from/to (RFC 3339 bounds on started_at, default all-time). Missing or garbage campaign_ids → 400.
{
"totals": {
"campaign_id": null,
"unique_accounts": 1000,
"unique_consumers": 940,
"attempted": 500,
"reached": 250,
"effective_contact": 250,
"attempted_pct": 50.0,
"reached_pct": 25.0,
"effective_contact_pct": 25.0
},
"by_campaign": [ { "campaign_id": "c1a2b3c4-...", "...": "same row shape" } ]
}Definitions (person-level dedup — each debt counts once):
attempted— ≥ 1 original attempt.reached— any non-failed disposition or a conversation.effective_contact—answered_humanor a conversation.
Compliance evidence
The durable, append-only artifacts that turn "we follow the rules" into "here is the row that proves it." Every number is a count over write-once tables, never an estimate.
GET /v1/stats/blocked?from&to&campaign_id
The blocked-dial rollup: by-gate counts + a daily series over the durable gate_blocks evidence (one row per debt-gate-campaign-UTC-day the compliance engine REFUSED to dial, written before the lead is skipped).
{"from": "...", "to": "...", "campaign_id": null, "total": 5,
"by_gate": {"quiet_hours": 4, "dnc_listed": 1},
"daily": [{"date": "2026-06-01", "total": 3, "by_gate": {"quiet_hours": 2, "dnc_listed": 1}}]}from/to RFC-3339 over occurred_at (default trailing 30 UTC days); campaign_id optional UUID. The gate vocabulary is the engine's reason codes (tcpa_no_consent, quiet_hours, regf_7in7, regf_post_contact, state_limit, dnc_listed, consent_revoked, cease_and_desist, number_reassigned, tenant_policy).
GET /v1/reports/violations-prevented?from&to
The packaged compliance artifact: per-gate totals, a per-campaign breakdown, the policy versions in force, and sample (masked) evidence rows. Add ?format=csv (or Accept: text/csv) for the report file, which carries its own x-artifact-sha256.
GET /v1/debts/{id}/defense-packet
ONE sealed JSON evidence bundle for a debt, assembled in a single transaction (consistent snapshot) from every durable table: the full CDR history (originals + corrections, each with its frozen compliance_snapshot), Reg F conversation anchors, every gate refusal, consent state, DNC (tenant/global/reassigned), cease-and-desist, promises, callbacks and supervision actions.
{"packet": "litigation_defense", "packet_version": 1, "...": "...",
"integrity": {"algorithm": "sha256", "digest": "<hex>",
"canonicalization": "sorted keys, no insignificant whitespace, ..."}}Tamper evidence: integrity.digest is a SHA-256 over the canonical JSON of the envelope WITHOUT the integrity key — any third party can re-canonicalize and re-hash to verify. Add ?format=zip for the bundle as a downloadable archive (the response carries x-artifact-sha256).
Billing & plan
The subscription plan and the invoiceable charges.
The per-tier catalog is DATA, not code: it lives in the plan_tiers table and the sellable ladder is starter $25, growth $69, scale$119, enterprise $199 per seat/month, each row carrying its own monthly allowance (included_minutes, included_sms_segments, included_ai_minutes, included_dids). This repo vendors that catalog at data/system-catalog.json, with its provenance in data/system-catalog.SOURCE.json, and npm test fails if any price on these pages drifts from it.
What IS account-wide code (Dialer.Billing.Plan): a 3-seat billing floor, DID overage at $25 per 10 monitored DIDs over the tier quota, and a metered voice-AI add-on at $0.25/connected-min with a $200/month minimum while enabled. Since core#893 that line also applies the published volume tier and the design-partner discount, always in this order: tier allowance → volume tier → design-partner discount → monthly minimum. The minimum is compared LAST, so a discount cannot push a bill under it. The add-on is offered on the tiers whose catalog row declares an included_ai_minutes allowance — growth, scale and enterprise; starter leaves the field empty and is not eligible.
PATCH /v1/tenant
Account self-edit. The body accepts name and nothing else; any other key is ignored and the updated tenant is returned.
There is no self-service plan change on this API. The billing-bearing fields — tier, voice_ai_enabled and seats — decide the invoice, so a tenant bearer that could write them is a direct under-pay vector (downgrade the tier or disable the add-on at period end → cheaper bill). They live on the platform-admin surface (PATCH /v1/admin/tenants/{id}), behind the platform-admin token, which no tenant credential can mint. To change plan, seats or the add-on, ask your platform contact.
GET /v1/billing/summary?from&to
The invoiceable line items for the period, each amount computed server-side from real state (occupied seats, monitored DIDs, metered AI minutes):
{
"tier": "growth", "tier_label": "Growth",
"voice_ai_enabled": true, "currency": "USD",
"period": { "from": "...", "to": "..." },
"line_items": [
{ "kind": "seats", "billable_seats": 5, "unit_price_usd": 69, "amount_usd": 345 },
{ "kind": "did_overage", "overage_dids": 10, "included_dids": 2, "amount_usd": 25 },
{ "kind": "voice_ai", "enabled": true, "billable_minutes": 900.0,
"allowance_pool": "ai_minutes", "included_allowance": 100,
"overage_quantity": 800.0, "gross_amount_usd": 225.0,
"subtotal_usd": 200.0, "amount_usd": 200.0 }
],
"total_usd": 570.0
}The voice_ai item above is abbreviated. The tenant is on growth, whose catalog row includes 100 AI minutes: 900 metered minutes gross $225, the allowance takes its proportional share off, and 800 minutes remain billable at $200. That period stays under the volume threshold and carries no design-partner grant, so the subtotal is what gets billed; a period that crossed either would also carry the volume_tier_* and discount axes of the line.
Seats and DID overage are the current monthly recurring; voice_ai is metered over [from, to] (defaults: trailing 30 UTC days). Add ?format=csv for the invoicing artifact (with x-artifact-sha256).
GET /v1/stats/ai-usage?from&to&campaign_id
The metered voice-AI feed the add-on is billed from: total billable seconds/minutes, call count, an estimated charge at the configured rate, and a daily series. One row per answered AI call, billed exactly once. ?format=csv for the artifact.
Onboarding (provisioning a brand-new tenant + its first admin + a bootstrap key) is a platform-admin operation —
POST /v1/admin/tenants, gated by a separate admin token, not a tenant key. It is out of scope for tenant-facing integrations.
Metrics (port 9568)
GET /metrics serves Prometheus text on a separate port (9568), unauthenticated — keep it off the public ingress. Counters include dialer_api_requests_total, dialer_api_request_duration_milliseconds_*, dialer_compliance_decision_count (allowed/reason_code), dialer_call_fsm_transition_count, dialer_pacing_throttle_count, dialer_runner_lead_blocked_count, dialer_runner_attempt_write_failed_count, dialer_cdr_pipeline_error_count, and the dialer_agents_* checkin/checkout/transition counters. No tenant/call labels by design (bounded cardinality).
Error codes
See the error envelope table in the overview. Quick map: 400 bad_request (types/params, fail-closed), 400 invalid (domain invariant), 401 unauthorized, 403 forbidden (suspended tenant), 404 not_found (including cross-tenant and non-UUID ids), 409 conflict (lifecycle/status machines, duplicates, busy seats), 415 unsupported_media_type, 422 unprocessable (typed contracts: PTP, callback rules, reserved rewind filters), 500 internal.