Appearance
REST API
Español: API REST.
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/tenant/calling-window | The account's own calling hours (null when none). Shipped in core v0.87.0. |
PUT | /v1/tenant/calling-window | Set the account's weekly calling hours; they only narrow. dd_ key or account_admin. Same note. |
DELETE | /v1/tenant/calling-window | Remove the account's calling hours. Same note. |
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/users | Dashboard users and their open invitations. |
POST | /v1/users | Invite a person to the dashboard (202; no password). |
DELETE | /v1/users/{id} | Disable a user (never deletes). |
POST | /v1/users/{id}/enable | Re-enable a disabled user. |
POST | /v1/auth/password-reset/confirm | Redeem an invitation or reset link with the password the person chooses. |
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. |
GET | /v1/ai/voices | The governed catalog of voices for AI agents; ?language= filters. Shipped in core v0.87.0. |
POST | /v1/ai/agents/{id}/voice-preview | A short-lived signed URL of the agent's saved voice reading one fixed sentence. Published in core v0.88.0. |
POST | /v1/ai/agents/{id}/test | One turn of the agent's test conversation: no call, no conversation. Capped; see the test conversation's limits. |
GET/POST | /v1/rewind-segments | Saved rewind segments: list the live ones / save a named set of filters. |
PATCH | /v1/rewind-segments/{id} | Rename a segment or replace its filters. |
POST | /v1/rewind-segments/{id}/retire | Retire a segment (terminal). |
POST | /v1/debts/import | Bulk JSON lead import (idempotent). |
GET/POST | /v1/import-mappings | CSV import templates: list (current version each) / create (version 1). |
GET/PATCH/DELETE | /v1/import-mappings/{id} | One template; PATCH renames or appends a new version; DELETE archives. |
GET | /v1/import-mappings/{id}/versions[/{version}] | Every version of a template, oldest first / one version. |
GET | /v1/import-mappings/{id}/uses | Imports that named the template, with the version each used. |
GET | /v1/debts/{id} | Debt + contacts. |
GET | /v1/debts/{id}/call_attempts | Full durable CDR history of one debt. |
GET | /v1/consumers/{id} | Debtor card: debts, DNC / cease-and-desist, Reg F counter, next permitted window. |
GET | /v1/debts/{id}/defense-packet | Sealed litigation evidence bundle (JSON or ZIP). |
POST | /v1/debts/{id}/defense-packet/archives | Archive the ZIP with retention and an RFC 3161 time-stamp. Published in core v0.88.0. |
GET | /v1/debts/{id}/defense-packet/archives | List the debt's archive attestations. Published in core v0.88.0. |
GET | /v1/debts/{id}/defense-packet/archives/{aid}/attestation | Get the attestation, time-stamp token and a short-lived ZIP URL. Published in core v0.88.0. |
GET | /v1/call_attempts | Filtered, keyset-paginated CDR query. |
GET | /v1/reports/cdr | The same CDR query as canonical dialer.v1.CallDetailRecord protojson. |
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. |
PATCH | /v1/agents/{id} | Correct a seat or release it (status: inactive). |
POST | /v1/agents/checkin | Seat goes available (by sip_extension; optional device_mode). |
POST | /v1/agents/checkout | Seat goes offline. |
POST | /v1/agents/{id}/pause | Seat goes paused (body reason). |
POST | /v1/agents/{id}/resume | paused → available. |
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/contact-windows | Contact rate by the consumer's local hour, best call window, phones per person. |
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). |
POST | /v1/privacy/erasure-requests | Dry run (default) or execution of erasing one person; execution off by default (details). |
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.
json
{ "status": "ok", "service": "dialer-core", "version": "0.3.0" }Identity
GET /v1/me
The authenticated tenant. With an API key (dd_…), as below, that is all it returns; with a dashboard sign-in session (ddu_…, email and password) it also carries the user — see Dashboard users:
json
{
"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.
Dashboard users: invitations
The people on your team sign in to the dashboard with their own email and password. You do not create them and you never set their password: you invite them, and each person chooses their password when they accept (core v0.77.0, core ADR #182).
POST /v1/users — invite a person
json
{ "email": "[email protected]", "name": "Ops Admin" }202, always with this exact body:
json
{ "status": "invited", "message": "invitation recorded" }- The same answer whether the address is free or already has an account elsewhere. A free address receives an email with a single-use link to accept the invitation. An address that is already a user of another Dialer Digital account receives a notice that it already has an account, with no link. Both are
202with the body above, byte for byte, and both invitations are recorded and listed alike: an invitation tells you nothing about other accounts, and tells them nothing about yours. 409 conflictonly for your own people: the address is already a user of your account, active or disabled — you can see it in your ownGET /v1/users. A disabled one comes back withPOST /v1/users/{id}/enable, not with a new invitation.400 bad_requestif the body has apassword. The invited person chooses it.- The link is valid for 72 hours and works once. Inviting the same address again replaces the previous link: that is how you resend an invitation, and how you renew an expired one.
429 rate_limitedwhen your account has sent too many invitations in a short time (20 per one-minute window by default). Retry later.- Each address also has its own cap, across every account (3 invitations per window). Over it the invitation is still recorded and the answer is still the same
202, but no email leaves. If a person says the invitation never arrived, invite them again a little later. - Who can invite. Once the account has an active
account_admin, inviting takes anaccount_admindashboard login (ddu_…): amemberlogin and add_key get403 role_forbiddenand nothing is recorded (core#2334, core#2449). An account with no activeaccount_adminkeeps the older posture: thedd_key is how the first person gets invited. A disabled administrator does not count. - Who made it. The invitation records its author, which never changes: see
invited_byinGET /v1/users.
Accepting: the person chooses the password
The link opens the dashboard's /accept-invite page, which redeems it with POST /v1/auth/password-reset/confirm ({"token": "…", "new_password": "…"}) — the same route as a password reset. That is the moment the person is created in your account, with the password they chose (12 to 128 characters). A link that is unknown, expired, already used or replaced by a newer one — or whose address became a user somewhere in the meantime — is one identical 401. A password that is too short is 422, and the link still works.
GET /v1/users
Returns users and invitations. invitations are the open ones, newest first: pending until they expire, expired after. An invitation to an address that already has an account elsewhere is listed, and expires, exactly like one nobody opened. An accepted invitation leaves the list, and its person appears in users. Each invitation carries invited_by, who made it: kind is user (a dashboard login, and user_id is that person), api_key (a dd_ key, user_id is null) or operator. kind is null for an invitation recorded before this field existed. It is fixed when the invitation is made, so after a key is revoked the invitation it made still reads api_key.
DELETE /v1/users/{id} disables a user — rows are never deleted — and ends every session they hold. POST /v1/users/{id}/enable re-enables them; the sessions ended by the disable stay ended, so the person signs in again. Both take the same role as inviting: with an active account_admin, a dd_ key or a member login gets 403 role_forbidden.
An integration that provisions people with a dd_ key
If your integration invites, disables or re-enables people with a dd_ key, it stops working as soon as the account has an active account_admin: POST /v1/users, DELETE /v1/users/{id}, POST /v1/users/{id}/enable, POST /v1/api_keys and DELETE /v1/api_keys/{id} answer 403 role_forbidden. A leaked key can no longer invite a mailbox of the attacker's or mint more keys. Ways out: run those calls from an account_admin dashboard login, or ask Dialer Digital to turn on api_key_manages_people for your account, a platform setting (off by default; you cannot set it yourself) that gives the key its earlier reach back. Per-account settings user_management_admin_only and api_key_admin_only exist as well and are also platform-side.
Migrating from the old POST /v1/users (breaking in core v0.77.0)
Before core v0.77.0, POST /v1/users took a password, created the user at once and answered 201 with it; an address that was a user of any account was a 409. If your integration creates dashboard users:
- Stop sending
password. A body with it is400and nothing is recorded. - Expect
202with the fixed body, with no user and no id: the person does not exist until they accept. Follow the invitation inGET /v1/users(invitations), and the person inusersonce they accept. - Read
409as "already in your account", not as "taken elsewhere": an address taken by another account is now a202like any other.
Campaigns
Campaign lifecycle: draft → running ⇄ paused → completed (terminal) → archived. draft can also be archived directly. Two more states are written by the platform, never by a client:
suspended_balance: the prepaid balance crossed the account's suspension floor and the campaign was taken off the air. It returns torunningon its own when the balance recovers; there is no call to make. It is governed by the platform flagcampaign_balance_suspension_enabled.exhausted: the campaign has a population, nobody in it is dialable now, nothing is parked for a later retry and no callback is pending. It does not resume by itself:POST /v1/campaigns/{id}/startbrings it back torunning. It is governed by the flagcampaign_auto_stop_exhausted_enabled.
Both flags are platform-side: you do not see or set them, and this page does not say whether they are on for your account. 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
json
{
"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:
json
{
"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.
Calendar start and end
Shipped in core
v0.87.0(core#1501, core ADR #206), and in the API Reference.
POST /v1/campaigns and PATCH /v1/campaigns/{id} accept two nullable fields:
json
{
"default_timezone": "America/Chicago",
"starts_at": "2026-11-02T09:00:00",
"ends_at": "2026-11-30T17:00:00"
}- They are wall-clock values in
default_timezone. Use exactlyYYYY-MM-DDTHH:MM:SS, withoutZor a UTC offset. If both are present,ends_atmust be later thanstarts_at. Sendnullto clear either field. - The platform moves only the states the field names. With the capability enabled, the scheduler moves a voice campaign that is still
drafttorunningoncestarts_atis reached, provided its plan permits a start and itsends_athas not also passed. It moves a voice campaign that isrunningto terminalcompletedonceends_atis reached. It does not restart a campaign that a person paused or the platform markedexhausted; on an SMS campaign the fields remain stored and inert. - The operational flag is off by default. Storing these fields does not schedule anything until the platform operator enables
campaign_window_enabledfor the account. While it is off, a campaign starts only throughPOST /v1/campaigns/{id}/startand the platform does not complete it atends_at. - A manual start does not waive the end. If a person starts a campaign after its stored
ends_atwhile the capability is enabled, the next scheduler pass completes it again. Clear or moveends_atfirst when that is not wanted.
This calendar window controls campaign lifecycle. It does not replace the daily calling window, the optional per-day schedule, the account calling hours or legal quiet hours; those still decide whether an individual call may leave while the campaign is running.
Artificial-voice campaigns: the disclosure plays first, and an opt-out key is required
The four synthesized-voice types — tts_blast, tts_ivr, ivr_cascade and survey — follow two rules (47 CFR §64.1200(b)):
- Your disclosure opens every call. The prompt that is synthesized is your account's stored artificial-voice disclosure (
PUT /v1/artificial-voice-disclosure) followed by the campaign'stts_prompt, as one audio. In the menu types each replay of the menu repeats the disclosure too. Without a valid stored disclosure the campaign does not dial at all, andGET /v1/campaigns/{id}/statsreportsprompt_unavailable_reason: "disclosure_missing". Changing the disclosure re-synthesizes the prompt of every one of your TTS campaigns; each one waits until its new audio is ready. Storing it takes an administrator:PUT /v1/artificial-voice-disclosureaccepts a tenant (dd_) key or anaccount_adminlogin; amemberlogin gets403 role_forbidden(reading it withGETstays open). - No
optout_digit, no call. A campaign of these types needs anoptout_digit— one DTMF key,0-9,*or#. Without one, every attempt is blocked before dialing astenant_policy, under the ruletcpa.artificial_voice.optout.v1, with the decision sealed in the audit chain like any other block (see Errors & compliance). Saving the campaign without the key is still accepted; it is the dial that is refused. Setoptout_digiton every TTS, IVR or survey campaign you want to keep dialing.
The opt-out instruction: write it with the {optout_digit} marker
The consumer has to hear which key opts them out, right after the call identifies itself (47 CFR §64.1200(b)(3)). You write that sentence, in your own words and language; the platform puts the key in it:
- Put the marker
{optout_digit}where the key goes, in your stored disclosure — not the key itself. Each campaign replaces every{optout_digit}with its ownoptout_digitwhen it synthesizes the prompt, so the key the consumer hears is always the key that works, even when your campaigns use different keys. The marker is matched exactly: lower case, with the braces, no spaces inside. - Place it right after the sentence that identifies the call as automated, which is the first thing that plays.
- Only the disclosure is read for the marker. A
{optout_digit}in the campaign'stts_promptis not replaced — it would be read aloud as written. - Without the marker the campaign does not dial. The disclosure is still stored — it is a valid disclosure, and
PUT /v1/artificial-voice-disclosureaccepts it — but no TTS, IVR or survey campaign dials with it, andGET /v1/campaigns/{id}/statsreportsprompt_unavailable_reason: "optout_instructions_missing". Nothing is synthesized and nothing is dialed until the disclosure has the marker. - The key is checked first. A campaign with no valid
optout_digitis still refused per attempt bytcpa.artificial_voice.optout.v1(above), with or without the marker. The one exception is a campaign that has the olderai_voice: trueflag and no key: it is judged as the AI lane, so it is stopped before dialing with the sameoptout_instructions_missing. *and#are read however the voice reads them. If a campaign uses one of them, listen to how it sounds and word the sentence around it.
For example, in English:
This is an automated call from Acme Collections. Press {optout_digit} to stop receiving these calls.
And in Spanish:
Esta es una llamada automatizada de Acme Cobranzas. Pulse {optout_digit} para dejar de recibir estas llamadas.
A campaign whose optout_digit is 8 plays "…Press 8 to stop receiving these calls." before its tts_prompt. Changing the disclosure or the campaign's key is a new text: the campaign re-synthesizes it and waits for the new audio before it dials again.
Rewrite your disclosure before this applies
This rule applies from the core release that carries it. A stored disclosure without the marker — including one that already says "press 9" in words — stops every TTS, IVR and survey campaign of the account from dialing. Rewrite it with {optout_digit} in place of the key.
ai_voice campaigns are not judged by the key: the consumer opts out by saying so in the conversation.
Leaving a voicemail: composed, with your toll-free opt-out number
Since core v0.75.0 (core#1333) a tts_blast, tts_ivr, ivr_cascade or survey campaign with answering-machine detection can leave a voicemail, and only one the platform composes (47 CFR §64.1200(b)(1) and (b)(3)). On a machine it plays, as one audio: your stored disclosure (with the campaign's key in place of {optout_digit}), then the campaign's voicemail_message with the marker {optout_number} replaced by the number, read digit by digit. Two campaign fields, on POST and PATCH /v1/campaigns:
voicemail_optout_number— a US toll-free number (800, 833, 844, 855, 866, 877 or 888) in E.164, that must be an active opt-out route of your own account (dest_type: "optout"): whoever calls it back is added to your do-not-call list. A number with no such route, a route of another type, or a deactivated one is400 invalidnaming the field.voicemail_message— your text, up to 2000 characters, with{optout_number}where the number is spoken (400 invalidwithout it). For example: "Please call us back at {optout_number} to stop receiving these calls."
With any piece missing — detection off, no playable prompt, no number, no marker, or the opt-out route deactivated later (it is re-checked on every dialing round) — no voicemail is left and the machine is hung up on. Every other campaign type hangs up on a machine. The free-form voicemail_drop_media field is retired: the API rejects it, and a campaign that relied on it hangs up on machines until it is given voicemail_optout_number and voicemail_message.
Voice-AI campaigns
What changed for ai_voice campaigns between core v0.73.0 and v0.75.0, as your integration sees it:
The AI disclosure is ready before the first call (core
v0.75.0, core#1327, core ADR #180). The disclosure of your live AI agent is rendered to audio before the campaign dials, so it is the first thing the consumer hears. While the first render runs,GET /v1/campaigns/{id}/statssaysprompt_unavailable_reason: "prompt_rendering"; if it fails,"ai_disclosure_render_failed", the campaign does not dial and the render is retried on its own.ai_disclosure_readyistrueonce the audio for the current text is ready. Editing the disclosure renders it again, and the campaign waits for the new audio. (Corev0.77.0servesai_disclosure_render_failedandai_disclosure_ready; the generated API Reference does not list them yet.)No more AI calls than the platform can hold (core
v0.75.0, core#1326, core ADR #179). The platform does not start more voice-AI sessions than its environment can hold. When they are all in use, the campaign waits andtenant_throttle_reasonisai_capacity: the capacity is shared by every account, first come first served, and a place frees when a voice-AI call ends.ai_capacity_undeclaredmeans the environment has not declared its capacity, so no voice-AI call is placed;ai_capacity_unavailable, that the check could not decide, so it refused. See the account-wide brake.A session refused for capacity does not hang up in silence. If a call is answered and the AI session is refused for capacity, the platform first tries to hand it to a free agent. If none takes it, the consumer hears the abandoned-call identification message (the seller name and callback number Dialer Digital configures on your account) and the call ends, recorded as
abandoned: nobody, person or AI, spoke to the consumer. The message is rendered before dialing;abandon_media_readyin the stats says whether it is ready (served by corev0.77.0, not listed in the generated API Reference yet). Without the seller identity the campaign still dials, and a refused session hangs up without the message.No free seat, the conversation can stay with the AI (core
v0.73.0, core#1325). When the AI tries to hand the call to a person and your account has no seat free, core no longer forces the fixed apology and hang-up if the voice-AI runtime asks to continue: the conversation goes on with the AI. The call staysai_conversation, and the 30-day abandonment report counts it apart ashandoff_failed, outside the rate.An AI conversation counts as a conversation for Reg F (core
v0.73.0, core#1323, core ADR #175; 12 CFR 1006.14(b)(2)(ii)). An answered outbound AI call that detection did not flag as a machine starts the 7-day wait after a telephone conversation about the debt, as a call with an agent does: that debt is not dialed again inside the window. It errs on the side of blocking — a failed handoff or an AI session lost mid-call also start the wait. In penetration analytics these calls count as an effective contact.The agent's voice comes from a governed catalog (core#1539, core ADR #199; shipped in core
v0.87.0).GET /v1/ai/voiceslists the voices an AI agent can speak with:{"voices": [{"voice_id", "provider", "language", "label"}]}, the same for every account, readable with anydd_key;?language=filters by the primary subtag (en-USselectsen) and a language with no voice answers200with[]. OnPOSTandPATCH /v1/ai/agents, avoice.voice_idthat is not in the catalog is422 unprocessablenaming the id; a body with novoice_idis fine, and an agent saved when the id was free text keeps working: the id it already stores is still accepted onPATCH. The AI disclosure is rendered to audio with that agent's voice, so what the consumer hears first is spoken by the same voice as the rest of the call.Hear the agent's voice before a consumer does (core#1537, core ADR #199).
POST /v1/ai/agents/{id}/voice-previewtakes no body and answers200 {"voice_preview": {"url", "voice_id", "language", "text", "cached"}}: the agent's saved voice reading one fixed sentence (English or Spanish, picked by the voice's language; you cannot send your own text).urlis signed on each response and short-lived, so request a fresh one instead of keeping it. The audio is rendered once per voice and sentence and shared, so a repeat request answerscached: truewithout a new render. Only a request that has to render counts against a per-account budget: over it the answer is429 rate_limitedwithRetry-After, and a cached request is never refused.404for an agent that is not yours,422for an agent with novoice.voice_id, one outside the catalog (a real call would not use that voice), or a voice whose language has no sample sentence, and503 voice_preview_unavailablewith the failing stage inerror.reasonwhen the speech service cannot produce the audio: you never get an empty file. Addw_browser token is403. Published in corev0.88.0; it is in the generated API Reference.The agent's test conversation is capped (core#2421, fixed in core#2431 and voice-ai#249; core
v0.129.0, voice-aiv0.29.0).POST /v1/ai/agents/{id}/testtakes{"message", "session_id"}(session_idoptional: omit it to open a thread, echo the one you got back to continue) and answers200 {"session_id", "reply"}. It is never a call, never a row inai_conversationsand never a dial, and a refused turn is none of those either. Every turn spends the house model, so it passes these gates before the runtime, in this order:400 bad_requestfor a missing or emptymessage; for amessageover 4,000 bytes (message is too long (max 4000 bytes); bytes, not characters); and for asession_idover 128 bytes (session_id is too long (max 128 bytes)). Source: corelib/dialer/voice_ai/sandbox_guard.ex:30-31,lib/dialer/api/v1_router.ex:15559-15566,:15600-15616.403 synthetic_tenantfor a synthetic demo account, which does not spend the house model. Addw_browser token is also403. Source:lib/dialer/api/respond.ex:253-260,lib/dialer/api/v1_router.ex:15618.429 rate_limitedwithRetry-After(the seconds left in the current 60-second window) over 30 turns per window per account, or over 12 per window within onesession_id. The limits are fixed (not tunable) and fail closed: with the limiter down the test is refused rather than run unmetered. Source:lib/dialer/api/mint_limit.ex:433-439,config/config.exs:238-239and:263,lib/dialer/api/v1_router.ex:15591-15598.503 sandbox_budget_unavailablewhen the inference budget brake does not grant the turn's estimate (a bound on the prompt plus the model's maximum output): nothing is sent to the model. Retry later. Source:lib/dialer/voice_ai/sandbox_guard.ex:113-134,lib/dialer/api/v1_router.ex:15621-15628.
Past the gates,
503 sandbox_unavailablestill means the AI runtime is not configured or unreachable, and502 bad_gatewaythat it failed or answered something unexpected. Inside a session the runtime keeps about the last 24,000 bytes of history (oldest turns drop first, the agent's instructions stay), forgets a session idle for 900 seconds and holds at most 256 sessions, evicting the least recently used. Source: voice-aisrc/voiceai/sandbox.py:47-50,:119-125.Going
liveasks for explicit guards (core#2459, core ADR #298; core CHANGELOG, 0.130.0).PATCH /v1/ai/agents/{id}withstatus: livechecks that the agent'spolicycarriesmax_settlement_pct,min_payment_centsandmax_plan_months, and at least one non-blank phrase inprohibited; withtake_payments: "card_on_file"it also needs ahandoff_rulesentry whosetriggerispayment_over. The platform settingai_agent_live_requires_guards(off,observeorenforce; defaultobserve) decides what a gap does:observe:200as usual, withwarnings: [{"code": "policy_incomplete", "message", "missing": [...]}], wheremissinglists the absent keys amongmax_settlement_pct,min_payment_cents,max_plan_months,prohibitedandpayment_over.enforce:422with{"error": {"code": "policy_incomplete", "message", "missing": [...]}}. It refuses the move intoliveand an edit of a live agent that adds a gap it did not have; a live agent that already existed without guards is not cut off and receiveswarnings.off: no check and nowarnings.
Send the keys in the same
PATCHor earlier, and readwarningson every200for a live agent. Source: core v0.134.0lib/dialer/voice_ai.ex:198-213,:215-287,lib/dialer/api/respond.ex:337-345,lib/dialer/api/v1_router.ex:10822. The console shows the warning and the 422 with the missing keys (dashboard#1344).take_payments: "card_on_file"needs the tenant settingai_take_payments_enabled(core#2463). The setting is off by default: nothing collects money yet, and the AI only records a payment commitment. While it is off, a write that setspolicy.take_paymentstocard_on_fileon an agent that does not already store it is refused with422(policy.take_payments card_on_file is not available: no payment provider is connected (ai_take_payments_enabled is off); use none). An agent already stored withcard_on_filecan still be saved with it, and the platform serves it to the voice runtime asnoneuntil the setting is on. Sendnoneinstead. The console shows “Charging a card on file is not available. Leave Take payments on No.” and offers the card option only to an agent that already stores it (dashboard#1477). Source: corelib/dialer/voice_ai.ex:326-355,lib/dialer/api/respond.ex:358-369,lib/dialer/flags/catalog.ex:1256,openapi/dialer-v1.yaml:32281.
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_configuredis no longer accepted here: the schema is closed and sending it is400 bad_request. The artificial-voice disclosure for the non-AI path is stored once withPUT /v1/artificial-voice-disclosureand resolved per attempt, instead of being asserted at startup.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:
json
{
"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.
A campaign that is running and does not dial: the account-wide brake
Some brakes stop every campaign of the account at once: billing and capacity limits, never compliance. Since core v0.73.0 (core#1320) the stats say which one, instead of a running campaign with every counter at 0:
json
{
"tenant_throttled": 120,
"tenant_throttle_reason": "exposure_limit",
"tenant_throttle_by_reason": { "exposure_limit": 120 },
"tenant_throttle_last_at": "2026-09-29T16:58:00Z"
}tenant_throttled— dialing rounds halted by such a brake, cumulative for the runner.tenant_throttle_reason— the brake in force now, ornullwhen none is. A round that places a call and does not end on a brake clears it.tenant_throttle_by_reason—tenant_throttledsplit by reason.tenant_throttle_last_at— when a brake last halted a round; it stays after the brake clears.
tenant_throttle_reason | What stopped the dialing |
|---|---|
plan_inactive | The account plan is not active. |
spend_cap / account_spend_cap | The voice-AI budget for the month is reached — this account's, or the company's summed over all its accounts. |
wallet_insufficient | The prepaid balance does not cover a call. |
plan_exhausted | The postpaid credit, or the plan's included allowance for the period, is used up. |
exposure_limit | The spending ceiling that applies while the account's identity check (KYC) is pending is reached; it grows each day. |
tenant_cps / tenant_channels / carrier_cps | The account's calls-per-second or simultaneous-call limit, or the carrier's calls-per-second limit. |
capacity_unavailable | The capacity check could not decide, so it refused. |
ai_capacity / ai_capacity_undeclared / ai_capacity_unavailable | Voice-AI campaigns only — see voice-AI campaigns. |
The campaign stays running and keeps trying on its own; it dials again when the brake lifts. These are not compliance blocks: they leave no evidence row and do not appear in GET /v1/stats/blocked. throttled is a different counter: the pacing brake of the campaign itself.
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=7json
{
"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}/rewindjson
{
"dispositions": ["no_answer", "busy"],
"amd_verdicts": ["machine", "human"],
"max_attempts": 3,
"last_attempt_older_than_days": 7,
"keep_scheduled_callbacks": true
}json
{ "requeued": 172, "callbacks_cancelled": 0 }Rules:
- Filters:
dispositions(matched against the debt's latest disposition),amd_verdicts(matched against the latest placed attempt),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_verdictsacceptsunspecified | human | machine | not_sure; an unknown value returns422 unprocessable(fail-closed, never silently ignored). Bad filter types return400.- Instead of loose filters you can send
{"segment_id": "..."}— a saved segment. Both the preview (POSTbody andGET ?segment_id=) and the execute call accept it. Mixingsegment_idwith any filter is400; a segment that is unknown, retired or another tenant's is404.
Saved rewind segments
A supervisor who repeats the same rewind every week can save its filters under a name. A segment stores the filters, never a list of accounts: using it re-evaluates it against the data of that moment, so there is no second copy of personal data to go stale or leak. Shipped in core v0.87.0 (core#1554), and in the API Reference.
POST /v1/rewind-segmentsjson
{
"name": "Voicemail, 3+ attempts, last week",
"filters": { "dispositions": ["no_answer", "voicemail"], "max_attempts": 3, "last_attempt_older_than_days": 7 }
}json
{
"rewind_segment": {
"id": "…", "name": "Voicemail, 3+ attempts, last week",
"filters": { "dispositions": ["no_answer", "voicemail"], "max_attempts": 3, "last_attempt_older_than_days": 7 },
"created_by": "…", "created_at": "…", "updated_at": "…", "retired_at": null, "retired_by": null
}
}Then rewind with it:
POST /v1/campaigns/{id}/rewind
{ "segment_id": "…" }Rules:
filtersgoes through the same validator as the rewind itself: a filter set the rewind would refuse is422 unprocessableat save time, and an unrecognised key is422naming it. A segment whose stored filters stop validating answers422when used — never a wider rewind.nameis trimmed, 1–80 characters, and unique among the tenant's live segments without regard to case (409 conflictotherwise). A retired segment frees its name.GET /v1/rewind-segmentslists the live segments.PATCH /v1/rewind-segments/{id}renames and/or replacesfilters(no merge); an edit that changes nothing writes nothing.POST /v1/rewind-segments/{id}/retireis logical and terminal: nothing is deleted, the segment leaves the list and cannot be used or edited again (404).- Every save, edit and retirement leaves an append-only audit fact (who, when, and the name and filters that resulted).
- Segments are per tenant: another tenant's segment is
404.
Debts / leads
Import mapping templates
/v1/import-mappings stores the CSV column mapping server-side, per tenant, so a team that imports the same file every week keeps its mapping across browsers and people.
- Create
POST /v1/import-mappingswithname(unique among the tenant's live templates,409otherwise),mapping(an ordered list of{column, field}; at least one entry must map to something other thanskip) and optionaloptions(separator:comma|semicolon|tab;default_currency: three uppercase letters). The server checks the shape offield, not a closed vocabulary: each consumer fails closed on a field it does not know. A tenant holds at most 500 live templates and a template at most 1 000 versions (422). - Versions are append-only.
PATCHwith a changedmappingoroptionswrites version n+1 and leaves version n exactly as it was; an edit that changes nothing writes no version. Sendbase_versionto refuse the edit (409) if someone else edited first. - Archive, never delete.
DELETEarchives (idempotent): the name is freed, and the template can no longer be edited or named by an import. Its versions stay readable. - Use in an import. Pass
?mapping_id=<id>onPOST /v1/debts/importorPOST /v1/debt-lists/{id}/import; the import records which version it used and how many items went in and failed, readable atGET /v1/import-mappings/{id}/uses. On the multipart upload,mapping_idreplaces themappingandseparatorparts: send one or the other; sending both is refused.
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.
json
{
"debts": [
{
"external_ref": "demo-1001",
"consumer_ref": "consumer-1001",
"debt_type": "other",
"account_number": "4111-XXXX",
"debtor_name": "Ana María Pérez",
"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
}
]
}
]
}json
{ "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.debtor_name(optional) is the debtor's name as free text. Leading and trailing whitespace is trimmed and runs of whitespace collapse to one space; a blank value is stored as no name. The limit is 200 characters after that: a longer name fails that item with"debtor_name should be at most 200 character(s)"(the error never repeats the value), and a value that is not text fails with"debtor_name is invalid". It is stored encrypted and is not searchable: there is no filter on it, and it is absent from theGET /v1/debtslist. The multipart CSV upload takes it as thedebtor_nametarget of themappingand validates it per row exactly as the JSON import does. An import is a snapshot of the item: re-importing a debt (sameexternal_ref) withoutdebtor_nameclears the name it had, like every other field of the item. Shipped in corev0.87.0(core#1529), and in the API Reference.- 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.
Reg F migration seeds
Two optional fields on each item let a client that migrates a book of business from another dialer declare the Reg F history this platform could not otherwise know, so the frequency and cooldown gates start from the client's real history instead of zero. Both are per-item and fail-soft, like every other field on this route; omitting either changes nothing.
prior_attempts_7d(integer ≥ 0) — how many calls about this debt the client already placed from its previous system in the last 7 days. It is materialized as attempt rows the gate already reads, so the 7-in-7 counter (reg_f.attempts_in_windowon the debtor card) starts from that number. It is a total for the window, not a delta: re-sending the same payload adds nothing, and re-sending a smaller number never retracts history — the count only goes up, likelast_conversation_at. A value above 7 is stored as 7 (7 already saturates every frequency cap the engine has). A value that is not an integer ≥ 0 fails that item.last_conversation_at(RFC 3339 with a UTC offset, never in the future) — when the client last spoke with the debtor in its previous system. It seeds aconversationsrow withsource: cms_importand advances the debt'slast_conversation_atanchor, so the 7-day post-contact cooldown (12 CFR 1006.14(b)(2)(i)(B)) blocks the next call inside the window instead of starting blind. Re-sending the same payload adds nothing, and an older date never moves the anchor back. A malformed value, one without a UTC offset, or one in the future fails that item; these conversations do not count as platform activity in penetration, abandonment or the attempts export. Shipped in corev0.90.0(core#1633, ADR #212), and in the API Reference.
In the CSV upload the same two fields are mapping targets with the same names — prior_attempts_7d and last_conversation_at — mapped to a header like any other debt target:
json
{ "external_ref": "account", "prior_attempts_7d": "attempts_7d", "last_conversation_at": "last_call", "contact.phone_e164": "phone" }A mapped last_conversation_at column goes through the same validation as the JSON field and seeds the same cms_import conversation; a bad cell fails only its row, and a blank cell is the same as omitting the field.
json
{
"debts": [
{
"external_ref": "demo-1001",
"consumer_ref": "consumer-1001",
"prior_attempts_7d": 3,
"last_conversation_at": "2026-10-03T14:00:00-05:00",
"amount_cents": 50000,
"currency": "USD",
"contacts": [ { "phone_e164": "+13125551001", "is_primary": true } ]
}
]
}GET /v1/debts/{id}
json
{
"debt": {
"id": "d0e1f2a3-...",
"tenant_id": "0a1b2c3d-...",
"campaign_id": "c1a2b3c4-...",
"external_ref": "demo-1001",
"consumer_ref": "consumer-1001",
"debt_type": "other",
"account_number": null,
"debtor_name": "Ana María Pérez",
"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
}
]
}
}debtor_name is null when the import carried no name. It is read here, on the debtor card and, for the agent's own calls, on GET /v1/calls of the widget; it does not travel to ClickHouse, outbound webhooks, the litigation defense packet or the logs.
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.
GET /v1/consumers/{id}
The debtor card. {id} is the consumer_ref your CMS sent on import (1 to 128 characters, not blank, no NUL byte); the platform has no separate consumers record, and consumer_ref is the key the Reg F counter already uses. The response is {"consumer": {...}}:
json
{
"consumer": {
"id": "consumer-1001",
"identity": "consumer_ref",
"generated_at": "2026-10-04T15:00:00Z",
"debts": [
{
"id": "d0e1f2a3-...",
"external_ref": "demo-1001",
"debtor_name": "Ana María Pérez",
"state": "open",
"primary_phone_masked": "***1001",
"reg_f": {"scope": "debt", "attempts_in_window": 2, "cap": 7, "window_days": 7,
"last_conversation_at": null, "status": "ok", "eligible_at": "2026-10-04T15:00:00Z"},
"window": {"status": "permitted_now", "reason": null, "at": "2026-10-04T15:00:00Z",
"destination_timezone": "America/Chicago",
"destination_local_time": "2026-10-04T10:00:00-05:00"}
}
],
"debts_truncated": false,
"cease_and_desist": {"on_file": false, "records": []},
"dnc": {"contacts_total": 1, "contacts_checked": 1, "contacts_listed": 0,
"lists": {"federal": 0, "state": 0, "internal_tenant": 0, "internal_global": 0},
"truncated": false},
"next_window": {"status": "permitted_now", "reason": null, "at": "2026-10-04T15:00:00Z",
"destination_timezone": "America/Chicago",
"destination_local_time": "2026-10-04T10:00:00-05:00"},
"evaluated_gates": ["regf.7in7.v1", "state.matrix.v1", "fdcpa.quiet_hours.v1"],
"not_evaluated_gates": ["tcpa.consent.v1", "dnc.lists.v1 (provisioning and RND freshness)",
"ai_disclosure", "artificial_voice", "campaign state"]
}
}(The example is abridged: each debt carries every field of the GET /v1/debts list summary, plus reg_f and window.)
- Derived, not stored. Nothing is accumulated for the card. It is computed per request from the append-only call, conversation, cease-and-desist and DNC records, and
generated_atis the compliance-clock instant every figure was evaluated at. - The window is a verdict of the real checks.
evaluated_gateslists the time-dependent rules behindwindowandnext_window;not_evaluated_gateslists what they do not cover, sopermitted_nowis not a promise that a campaign will dial the number.statusispermitted_now,scheduled(earliest permitted instant inat, looked for over the next 9 days),suppressed(a DNC listing or a cease-and-desist forbids contact),not_dialable(the debt's state is not dialable) orundecidable(the card cannot decide and gives thereason; it is never read as permission). - The debtor's name. Each debt carries
debtor_name: the name the import sent for that account, ornull. It is decrypted only for this response; theGET /v1/debtslist does not carry it. - No phone numbers. DNC state is counts per list. Each debt's phone is masked (
***+ last four), as inGET /v1/debts. - Errors.
400 bad_requestfor anidthat is blank, longer than 128 characters or contains a NUL byte;404 not_foundfor a consumer with no debts in your tenant, which is also what you get for aconsumer_refthat exists only in another tenant.
Debt lists: members
A reusable list (/v1/debt-lists) holds debts; importing into it never takes one out. Two verbs read and take out its members (a debt that is not a member removes nothing and is not an error):
GET /v1/debt-lists/{id}/membersreads the members, oldest entry first, in pages of up to 500 (?limit=, a larger value is capped) with an opaque?cursor=(thenext_cursorof the previous page;nullon the last one). Each row carriesdebt_id, the client'sexternal_refandadded_at: no phone and no debtor name. An archived list is still readable;404when the list is not yours.DELETE /v1/debt-lists/{id}/memberstakes up to 500 debts out in one call, with the body{"debt_ids": ["…"]}(a body on aDELETE) and answers200 {"removed": N}, the count that actually left.400whendebt_idsis empty, has more than 500 or carries a value that is not a UUID, and then nothing leaves.DELETE /v1/debt-lists/{id}/members/{debt_id}is the one-debt twin.
The debts are not deleted; they only stop being members, and so stop being worked by the campaigns that had them through this list (unless they are in them another way). The platform does not record which import put a debt in, so there is no "undo this import": read the external_refs and take them out in batches.
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 | Exact match. Engine values: answered_human, abandoned, delivered, ai_conversation, answered_machine, ai_stream_lost, busy, no_answer, canceled, rejected, failed — plus typed agent/AI outcomes such as promise_to_pay. An unknown code returns 200 with no rows. See answered calls with no bridge. |
from, to | RFC 3339 | Bounds on started_at. |
limit | int | Default 100, max 1000. |
cursor | string | Opaque keyset cursor from the previous page. |
json
{
"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.
Answered calls with no bridge: named for what they were (breaking in core v0.73.0)
Until core v0.73.0, every outbound call that was answered and never bridged to an agent was recorded as abandoned. Since v0.73.0 (core#1323, core ADR #175) it is named for what happened:
| Disposition | When |
|---|---|
answered_machine | Answering-machine detection said machine, whatever the campaign type. |
ai_conversation | A voice-AI campaign: the consumer talked to the AI. A handoff to an agent that found no free seat is still ai_conversation. |
delivered | A TTS, IVR or survey campaign: the recorded message or menu was delivered. |
abandoned | Only when an agent was expected and none took the call. |
- Forward only. Rows written before
v0.73.0keepabandoned. A report, filter or export that looked forabandonedto find delivered messages or AI conversations must look for the new codes from that version on — and keepabandonedfor the older rows. - Your retry rules came along. The upgrade copied every rule you had written for
abandonedtodelivered,ai_conversationandanswered_machine, with the same settings, unless you already had your own rule for that code; each copy is in your rules history and can be removed like any rule. An account with the platform's recommended cadences now sees itsanswered_machinerule apply to machines reached by the predictive dialer, which used to fall underabandoned. - Contact scoring does not change: the new codes score exactly like the
abandonedthey replace.
GET /v1/reports/cdr
The same rows as GET /v1/call_attempts, in the canonical protojson of dialer.v1.CallDetailRecord. It takes exactly the same query parameters (filters, limit, cursor), pages with the same keyset cursor, and is isolated per tenant the same way; only the wire shape differs. It shipped in core v0.87.0 (core#1472) and is in the API Reference.
json
{
"callDetailRecords": [
{
"callId": "0190a1b2-0000-7000-8000-000000000001",
"tenantId": "0190a1b2-0000-7000-8000-0000000000aa",
"toNumber": "+13125550100",
"startedAt": "2026-10-04T12:00:00.250Z",
"answeredAt": "2026-10-04T12:00:05.250Z",
"endedAt": "2026-10-04T12:01:05.250Z",
"durationSeconds": 65,
"billsec": 60,
"disposition": "DISPOSITION_ANSWERED_HUMAN",
"hangupCause": "NORMAL_CLEARING",
"sipResponseCode": 200,
"recordVersion": 1
}
],
"nextCursor": "opaque"
}- Protojson, not the
/v1/call_attemptsshape. Keys are lowerCamelCase, enums are their names (DISPOSITION_ANSWERED_HUMAN), timestamps are RFC 3339 in UTC, and a field at its proto3 default is omitted.nextCursoris therefore absent on the last page, where/v1/call_attemptsanswersnext_cursor: null. - Nothing beyond the CDR query. The record carries only what
/v1/call_attemptsalready publishes. Contract fields with no source in a call attempt (direction,mediaPath, the carrier fields,attestation,costWeight) are omitted, not guessed. - One row is one record. The table is append-only, so a correction is its own record; a row with no channel UUID uses its attempt id as
callId, and a correction carries the id of the original it corrects. - The disposition enum is closed, the REST vocabulary is open. A code that is in the contract enum goes in
disposition; any other (abandoned,delivered,promise_to_pay, …) is left out ofdispositionand travels inlabels.disposition_code. Thedispositionfilter keeps matching the REST value.
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.
bash
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. GET /v1/callbacks pages the same way in its queue view (status=…): next_cursor goes back as cursor, opaque, and is null on the last page. The due=now feed is not paged. promises takes limit (max 1000) and is not paged yet.
Lists with a ceiling: truncated
Some lists are not paged: core returns up to a fixed number of rows and says so. GET /v1/users (500 users and 500 open invitations), GET /v1/agents (seats, 2 000), GET /v1/campaigns (2 000), GET /v1/dids (numbers, 2 000) and GET /v1/debt-lists (lists, 2 000) answer a truncated boolean next to the array. truncated: true means there were more rows than the ceiling: what you received is a prefix in the documented order, not the whole set, and there is no cursor to fetch the rest. Never read a truncated: true list as complete: show the cut to the user, or narrow the request.
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.
json
{
"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:
json
{
"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"
}
}Collection-notice attestation
Release status: these contracts are delivered in core
main(core#1587, ADR #211), after corev0.87.0. This site's generated API Reference follows published releases and does not list them yet.
POST /v1/call_attempts/{id}/disclosure appends the agent's declaration about the collection notice. There is no default outcome:
json
{
"agent_id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"outcome": "not_delivered",
"reason": "consumer_hung_up"
}outcome is delivered or not_delivered. The latter requires a reason from the contract's closed vocabulary; reason: other may carry a bounded note. script_sha256 may identify the notice text shown to the agent. To correct the current declaration, append another one with its supersedes_id; rows are not edited or deleted. The response is 201 with the DisclosureEvent under disclosure.
The same fields, without agent_id, may travel as the optional top-level disclosure field of POST /v1/call_attempts/{id}/disposition: there the attestation takes the disposition's author, and any other key (agent_id included) is a 400 that writes nothing. The attestation, disposition correction and any promise commit in one transaction. A rejected attestation writes none of them.
GET /v1/call_attempts returns disclosure.status as one of: attested, declared_not_delivered, unattested, not_applicable or undecidable. It also returns the current event_id, its server timestamp in at, and revised. GET /v1/reports/attempts appends the same state as the disclosure_status CSV column. That export also takes campaign_id (a uuid; a malformed one is a 400) and disposition (matched exactly against the final disposition the CSV prints in outcome), as GET /v1/call_attempts does. A cursor is only valid with the filters it was produced under, so resume a filtered export with the same campaign_id and disposition. Defense packet version 2 carries every entry of disclosure_events, including rows superseded later, a summary and the state on each attempt.
These records state what the agent declared. They do not prove that the notice was spoken, heard or understood, or that its wording satisfies a rule. The tenant and its counsel own the jurisdiction-specific text; the API can retain its digest without approving its content.
Enforcement errors
The governed key disclosure_attestation_enforced starts false. When it is true, closing a call that has an effective debt and a final pipeline outcome of answered_human or transferred requires either an earlier attestation or the disposition body's disclosure field:
| HTTP | error.code | Client action |
|---|---|---|
422 | disclosure_attestation_required | Collect the agent's declaration and retry the disposition with disclosure, or append it first through the dedicated route. Nothing from the rejected close was written. |
409 | attempt_not_final | The final pipeline row does not exist yet. Retry after the call ends; do not treat the call as out of scope. |
A call without an effective debt is outside the requirement. Turning the key off only removes the close-time block; it never creates an attestation.
Operator runbook
The key is a security setting with writable_by: founder; a regular platform operator cannot change it. It starts off so core can deploy before the agent workspace has the attestation action. Enable it for a tenant only after that UI is deployed:
- Read
GET /v1/admin/tenants/{id}/configand locatedisclosure_attestation_enforced; confirm that the override is honoured. - With the founder-authorized admin credential, send
PUT /v1/admin/tenants/{id}/config/disclosure_attestation_enforcedwith{ "value": true }. - To lift the requirement, send the same
PUTwith{ "value": false }, orDELETEthe override to return to the catalog default (false). Neither operation changes existing disclosure events. - Read
GET /v1/admin/tenants/{id}/config-audit, newest first, and select rows whosekeyisdisclosure_attestation_enforced. For a shutdown, readold_value,new_value,active,actor,outcomeandchanged_at.
The configuration trail is append-only at the database level and records both applied changes and refused attempts. It is separate from the sealed compliance chain that covers the per-call disclosure events.
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.
json
{ "promises": [ { "id": "9a8b7c6d-...", "status": "pending", "amount_cents": 25000, "currency": "USD", "promised_date": "2026-06-21", "...": "..." } ] }PATCH /v1/promises/{id}
json
{ "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
json
{
"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 | wrap_up | paused. Presence is driven by checkin/checkout and the dialer itself — there is no presence-PATCH endpoint. wrap_up (after-call work, ACW) is entered by the dialer on the hangup of an answered call, only when the governed agent_wrap_up_seconds setting is above 0; it defaults to 0, so ACW is off by default. See the Roadmap.
GET /v1/agents
Durable roster merged with live presence:
json
{
"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
json
{ "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
json
{ "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.
PATCH /v1/agents/{id}
Corrects a seat and is the verb that releases it. Admin only (a dd_ key or an account_admin login; any other login is 403 role_forbidden). Send only what changes: name, email, role (agent|supervisor|admin), sip_extension and status (active|inactive); an unknown key is 400 naming it.
status: "inactive"releases the seat: it stops counting against the plan cap and the invoice, its liveddw_tokens are revoked, it is checked out and its SIP credential is evicted (sip_teardown:ok | skipped | failed). The row is never deleted.status: "active"on an inactive seat takes the same cap as creation:422 seat_limit_reachedor422 unpriceable_tier.409 last_admin_seatwhen the seat is the only activeadminseat and the edit would demote or deactivate it;409 seat_linked_to_userwhen it is linked to a console user and the edit changes its email;409 conflictwhen the email or the extension is already taken.
POST /v1/agents/{id}/pause / POST /v1/agents/{id}/resume
Takes a seat out of rotation (paused) and puts it back (available). Tenant scope only — a dd_ API key or a dashboard login; an agent (widget) token gets 403.
json
{ "reason": "break" }- pause —
availableorwrap_up→paused.reasonis required and must be one ofmanual | break | lunch | training | meeting; anything else (or a missingreason) →400 bad_request. - pause with
on_release: true—{ "reason": "lunch", "on_release": true }on aringingoron_callseat arms the pause instead of refusing with409: the seat stays on its call, the reply carriespause_armed: true, and when the call is released the seat goes straight topausedwith that reason, never throughavailable. Arming again is idempotent (the last reason wins) andresumedisarms it. On a seat with no call it behaves as the plain pause. The seat read (GET /v1/agents/{id}) carriespause_reason(why the seat is paused,nullunless it is),state_since(when it entered its current presence) andpause_armed. The dashboard's agent console offers its Pause when the call ends button only when the seat read carriespause_armed. - resume —
paused→available. No body.
Both return 200 {"agent": {...}} with the presence read back from the seat ("presence": "paused" after a pause). 404 unknown agent; 409 conflict if the seat is not checked in, or — for pause — is reserved, on a call (unless on_release: true) or already paused, or — for resume — is not paused.
POST /v1/agents/{id}/wrap-up
Closes the agent's after-call work: wrap_up → available. There is deliberately no verb that enters wrap_up; the dialer does that itself. 200 {"agent": {...}}; 404 unknown agent; 409 conflict if the seat is not in after-call work (or not checked in). The periodic sweep (the agent_wrap_up_seconds cap) and POST /v1/agents/{id}/pause are the other exits from wrap_up.
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).
json
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).
Transferred calls: legs and handle time
On core
main(core#2403, core ADR #297), after thev0.129.0tag: it is not in a tagged release yet, so the vendored API Reference does not list/v1/agents/handle-timeortransfer_chain. Source:openapi/dialer-v1.yamlandchangelog.d/core-2403.mdin coremain.
A call transferred A -> B -> C is rebuilt leg by leg from the append-only call_control_actions trail and the CDR clock. Nothing is stored apart: there is no new column to correct.
transfer_chainonGET /v1/calls/{call_id}. The call carriestransfer_chain, the legs in order,[]when nobody transferred it. A chain that cannot be read is503, never[]. Each leg hasseq(from 1),role,agent_id,destination,via,started_at,ended_atandduration_ms.roleiscontrol(the agent holds the call) orconsult(the agent is on the consultation leg of an attended transfer).agent_idisnullwhen control left the agent population (an external transfer or a take-over), or for a consultation recorded before the agent was stamped.viaisoriginfor the agent the call started with; otherwise the verb that opened the leg (blind_transfer,attended_transfer_complete,attended_transfer_start, ...).destinationis the extension or number that verb addressed, empty on the origin leg.started_at,ended_atandduration_msarenullwhile the leg is open or was never answered: they are never invented.
GET /v1/agents/handle-time. Takesfrom(inclusive) andto(exclusive) as RFC 3339, plus an optionalagent_id; the window counts the calls that started in it, spans at most 31 days (400otherwise) and at most 10,000 calls (422: refused, never truncated). Management credentials only (dd_orddu_); an agentddw_token is403. For each agent it answerscalls,control_ms(time holding the call),consult_ms(time on a consultation leg),occupied_ms(their sum) andaht_ms(control_ms / calls,nullwhen the agent held none). Each agent is credited only its own legs: A until the hand-off, B from there to the next, C to the end, not the whole call to the agent the CDR row names. A leg not yet measurable (the call is live) is left out and counted inexcluded.open_legs; control that went to an external number credits no agent.definitionis"v1"and never changes meaning.503when the evidence cannot be read.GET /v1/agents/occupancydoes not change. The origin seat stayson_calluntil the call ends, even after it transferred the call; usehandle-timefor who held it and for how long.
The report builder's call detail (CSV included) carries the same chain as two columns: see Report builder.
Inbound calls on a company-wide BYOC trunk
Published in core
v0.88.0(core#1565, core ADR #203). The route is the samePOST /v1/carriers, covered by the generated API Reference;scope: "empresa"andsource_ipsare described here.
An account can use one byoc trunk for inbound calls to DIDs owned by any of its offices. Register it with scope: "empresa" and the bare IPv4 or IPv6 addresses from which that SIP peer sends traffic:
json
POST /v1/carriers
{
"kind": "byoc",
"name": "company-primary",
"sip_proxy": "sip:pstn.example.test:5060",
"scope": "empresa",
"source_ips": ["203.0.113.9"]
}Creating a company-wide trunk requires an account_admin dashboard session; a member or machine API key gets 403. An office trunk (POST /v1/carriers without scope: "empresa") and its PATCH, provision and revoke take a dd_ key or an account_admin login: a member login gets 403 role_forbidden (core#2647; see Who can write). The account is always taken from that session: account_id in the body is ignored, and tenant_id is rejected. Each source address can belong to only one trunk that is not retired (active or disabled) across the platform; an address already assigned to any office-wide or company-wide trunk is 400 invalid with the message source_ips already assigned to another trunk. An empty source_ips list is valid, but that trunk then carries outbound traffic only.
What POST /v1/carriers refuses (core#2424, core#2345, core#2354):
kind: "house"is422. From this route onlykind: "byoc"is accepted: ahousetrunk belongs to the platform admin, who registers it through/v1/admin/carriers. The message names the field,kind.- A special-use address in
source_ipsis422. Loopback, private (RFC 1918), CGNAT, link-local, documentation, multicast and reserved ranges, and their IPv6 equivalents, are refused; the message names the field,source_ips. Use the public addresses from which your carrier sends. - A
sip_proxythat is not a publicsip:/sips:endpoint is422. That covers a malformed value, a loopback, private, link-local, CGNAT, multicast or unspecified address, a name internal to the cell (localhost, a single label,.local,.internal,.svc) and a range the cell declares as its own. - A
sip_proxyalready used by another account is409with codetrunk_endpoint_shared. Its host and port are already the endpoint of a platform carrier or of an active BYOC trunk of another account; two accounts cannot share one destination, two offices of the same account can.POST /v1/carriers/{id}/provisionapplies the same check (422or409) and pushes nothing.
The first two checks (kind and source_ips) are behind the flag carrier_source_ip_class_check, ON by default; trunks already stored are not changed.
Nothing is written when the answer is an error.
After registration, POST /v1/carriers/{id}/provision writes the trunk to the SIP edge. source_ips, sip_proxy and scope are provisioned values and cannot be changed with PATCH /v1/carriers/{id}; that route changes only allowed_destinations. POST /v1/carriers/{id}/revoke removes the inbound ACL, retires the trunk permanently and frees its source addresses for another trunk. Revoking a company-wide trunk, like creating one, requires account_admin.
The source IP proves which account the peer belongs to; it does not choose an office. The dialed E.164 number must have an active inbound DID route, and that route selects the office that receives the call. Therefore:
- a DID of any office in the same account can receive the call;
- a DID owned by another account, an unknown DID or an unclassified source IP is rejected at the SIP edge;
- a company-level number that rings several offices, restricting the trunk to a subset of offices, CIDR ranges and several accounts behind one source IP are not part of this surface.
The internal account=<company> value in the DID route and acct:<company> source classification are routing implementation details; API clients configure the supported behavior only through scope, source_ips and the account's DID routes.
BYOC trunks: destination allowlist
A byoc trunk can declare allowed_destinations: a list of exact E.164 numbers the account may call through it. It exists for accounts that must never reach anyone else — a QA account, or a prospect's trial that may only call the numbers the prospect confirmed as its own. Without a list, nothing changes.
PATCH /v1/carriers/{id}
{ "allowed_destinations": ["+13125550100", "+13125550101"] }- Declaring it.
POST /v1/carriersaccepts the field at creation,GETreturns it, andPATCH /v1/carriers/{id}replaces the whole list —[]ornulllifts it.allowed_destinationsis the only keyPATCHaccepts. Up to 100 numbers, strict E.164 (+and digits), no duplicates; the list is stored encrypted. Only abyoctrunk takes one. - Who can change it. A company-wide trunk binds every office that inherits it, so changing its list takes an
account_adminuser (403otherwise), as creating it does. A retired trunk is409; another company's trunk is404. - What it binds. Every call the platform places for that office towards the phone network: campaign dials (retries and due callbacks included), manual and preview dials, and the outbound leg of a blind transfer to an external number. When several active BYOC trunks of the office declare a list — its own and the company's — the number must be on each of them. The rule binds the office, not one trunk: a campaign routed through another carrier is restricted all the same.
- It fails closed. If the list cannot be read, the call is refused as if the number were outside it.
What you see when a destination is outside the list — nothing is dialed, and the refusal is filed as a tenant_policy block:
| Where | What you get |
|---|---|
| Campaign dialing | The attempt is blocked and counted under tenant_policy in GET /v1/stats/blocked. |
Manual/preview dial (POST /v1/campaigns/{id}/dial) | 200 {"status": "blocked", "reason": "tenant_policy"}. |
Blind transfer to an external number (POST /v1/calls/{call_id}/transfer) | 422 compliance_blocked with gate: "pre_dial", reason_code: "destination_not_allowed" (or "destination_allowlist_unavailable"), the rule_id and, when the evidence row was written, the decision_id. The consumer stays on the call. |
The rule is egress.destination_not_allowed.v1, or egress.destination_allowlist_unavailable.v1 when the list could not be read; it is visible on the decision in the audit chain (GET /v1/compliance/decisions/{id}).
Two limits to know:
- The compliance preflight and the rewind preview do not evaluate the list, so they can count as dialable a lead the dial itself then refuses.
- The list guards against dialing by mistake, not against your own administrator: whoever can manage the trunk can lift it.
BYOC trunks: SRTP
Published in core
v0.127.0(core#2333). Reference: thesrtpfield ofPOST /v1/carriers.
A byoc trunk can declare srtp, the encryption policy of the media on its outbound leg towards your provider. The console does not show the field yet: you set it through the API, when you create the trunk.
srtp | What it does |
|---|---|
off (default) | Plain RTP, exactly as before the field existed. Omitting srtp is the same as off. |
offer | The platform offers SRTP and accepts plain RTP if your provider answers without it. |
require | The platform refuses an answer that is not SRTP. |
http
POST /v1/carriers
{
"kind": "byoc",
"name": "Provider with TLS",
"sip_proxy": "sips:pstn.example.test:5061",
"srtp": "require"
}- The TLS condition.
offerandrequireneed asip_proxythat is TLS:sips:or;transport=tls. Otherwise the request is400 invalidwith the messagesrtp offer and require need a TLS sip_proxy. - Do not write
srtp=in the proxy. The platform composes that parameter itself; asip_proxythat already carries one is400 invalidwith the messagesip_proxy must not carry an srtp parameter; use the srtp field. Any value outsideoff,offerandrequireis also400 invalid. - Changing it.
srtpis a provisioned value likesip_proxy:PATCH /v1/carriers/{id}does not accept it. To change it, revoke the trunk and register another. - At the edge.
POST /v1/carriers/{id}/provisionwrites the proxy as<sip_proxy>;srtp=<mode>forofferandrequireonly; withoffit writes the same value as before. - House lines. Platform house trunks carry the same field, set by the operator; it is not a customer setting.
BYOC trunks: capacity ceiling and money holds
core#2430 (BYOC without a ceiling of its own), core#2425 (voice AI over a BYOC trunk) and core#2427 (answered inbound call), all on core
main. Nothing here adds an endpoint or a field: it changes which dials and calls are admitted, and what they hold.
A dial over the account's own byoc trunk spends the account's carrier, not the platform's, but it still rides the shared media pool. Three rules follow, with the code in core that backs each figure:
| Figure | Where it applies | Backed by (core main) |
|---|---|---|
| 100 simultaneous channels | A BYOC dial of an account whose row declares no max_concurrent_channels | byoc_assumed_channels, @default_byoc_assumed_channels in lib/dialer/dialer/capacity.ex |
| 20 calls per second | A BYOC dial of an account whose row declares no max_cps | byoc_assumed_cps, @default_byoc_assumed_cps in lib/dialer/dialer/capacity.ex |
| 20 simultaneous calls | A voice-AI dial over BYOC of a subscription or postpaid account with no ceiling of its own (instead of the 100 / 20 above) | @default_assumed_channels and @default_postpaid_assumed_channels in lib/dialer/billing/spend_guard.ex |
| 3,600 seconds | The default cut-off of an answered inbound call that the AI does not serve | inbound_max_call_seconds (INBOUND_MAX_CALL_SECONDS) in lib/dialer/inbound/call_handler.ex |
- Which ceiling rules a BYOC dial. Field by field, the account's own value always wins, in both directions: with
max_concurrent_channels: 500the dial admits 500. Only a missing field takes the assumed one. The ceiling of an account is written by Dialer Digital with the platform token (PATCH /v1/admin/tenants/{id}); the account cannot write it. BYOC dials count in their own lane while the ceiling is the assumed one, so they never take the places of the account's platform-line dials, and the other way round.GET /v1/stats/capacitydoes not change: it still describes the ceiling of a platform-line dial. - What the client sees when it denies. The dial is not made and the reason is
tenant_channels(the 101st simultaneous channel) ortenant_cps(the 21st in the same second). They are the same reasons as in the account-wide brake table: the campaign staysrunningand dials again when there is room. - Voice AI over BYOC.
ai_useddials over abyoctrunk now take the money brake, in the AI lane only: the carrier minutes carry no rate and no minutes pool. Prepaid holds the AI rate times the assumed window (zero balance:wallet_insufficient, nothing dialed); subscription counts only the AI-minutes allowance, past which the excess is paid from the balance; postpaid keeps its credit ceiling with the AI price as the only cost (plan_exhausted). A BYOC dial without AI is unchanged: no hold, and only the 100 / 20 ceiling. Prepaid has no assumed ceiling: its hold is its cap. - Answered inbound call. Before answering, the call takes one channel of the account (refused with
tenant_channelsorcapacity_unavailableif there is none) and passes the money brake: a prepaid account, or a subscription whose allowance is spent, takes a balance hold released when the call ends; a postpaid account, or a subscription inside its allowance, takes no hold but a duration ceiling. With no room the call is refused (wallet_insufficienton prepaid,plan_exhaustedon subscription or postpaid). A refused inbound call is filed with dispositionrefused_<reason>and hangup causeCALL_REJECTED. Every route the AI does not serve (agent, menu, queue) is cut at 3,600 seconds, or sooner if the hold's own ceiling is lower; the figure is a cell setting, not an account one. The same change checks the voice-AI monthly budget (ai_spend_cap) before the AI admits an inbound call, and the call falls to its queue when it is spent; since no AI answers inbound calls today, nothing about that check is observable yet.
BYOC trunk health
Shipped in core
v0.87.0(core#1522, ADR #196), and in the API Reference.
Two read-only endpoints expose the health stream of a byoc trunk:
GET /v1/carriers/{id}/healthreturns the latest health state, includingcarrier_id,state,reason,since,observed_at,fresh, and the latest probe/CDRsignals.GET /v1/carriers/{id}/health/eventsreturns the transition history, newest first, with keyset pagination (limitandcursor). By default it excludes five-minute confirmation rows; addinclude_confirm=trueto include them.
The current state is up, down, or unknown. unknown is the expected state before the first measurement; it also represents an unreadable or stale measurement. It is not an optimistic up. Both endpoints return 404 for a trunk outside the caller's scope, an unknown or malformed id, or a platform house trunk; the health response does not expose the trunk's SIP proxy.
Health collection is controlled by the platform-wide byoc_trunk_health_probe_enabled operational flag. It is off by default, and only an operator may turn it on. When off, no probe set is reconciled and the health evaluator does not write health events, so a previously unmeasured trunk continues to read as unknown. This signal is informational: it does not change call routing or relax a compliance gate.
Tenant calling hours
Shipped in core
v0.87.0(core#1527, ADR #197), and in the API Reference.
An account can declare its own weekly calling hours — "never before 9, never after 20" — once, instead of repeating them on every campaign. They are a third window over every campaign and they only narrow: a call leaves only when all three are open — the legal hours at the destination (quiet hours), the campaign's window, and the account's. The account's hours can never open a call the other two closed. Without them, nothing changes.
PUT /v1/tenant/calling-window
{ "timezone": "America/Chicago",
"weekly": { "mon": { "start": "09:00", "end": "20:00" },
"tue": { "start": "09:00", "end": "20:00" } } }- Shape.
timezoneis an IANA zone and is required: the hours are read on that clock, not the destination's (the destination's clock is the legal window, which stays in force).weeklymapsmon…sunto{start, end}; a day with no entry is closed,startmust be beforeend, and a window does not cross midnight. Exactly those two keys; anything else is refused. - Reading and removing.
GET /v1/tenant/calling-windowanswers{"calling_window": null}when the account has none, or the stored window with its times normalised toHH:MM:SS.DELETEremoves it and answers{"calling_window": null}. A stored value that cannot be read is503, never "no window". - Who can write. A
dd_API key, or a dashboard user with theaccount_adminrole; any other dashboard login can read but gets403 role_forbiddenonPUT/DELETE. Every write is audited with its author. - It fails closed. A malformed body is
422 unprocessablewith the reason and nothing is stored. If a stored value cannot be evaluated when a call is about to leave, the call does not leave.
What you see outside the account's hours — nothing is dialed, and the refusal is filed as a tenant_policy block:
| Where | What you get |
|---|---|
| Campaign dialing | The attempt is blocked, filed with its rule_id and counted under tenant_policy in GET /v1/stats/blocked. The lead is offered again after the block cool-down (60 seconds by default). |
Manual/preview dial (POST /v1/campaigns/{id}/dial) | 200 {"status": "blocked", "reason": "tenant_policy"}. |
The rule is tenant.calling_window_closed.v1 outside the hours, or tenant.calling_window_unavailable.v1 when a stored value could not be evaluated.
Two limits to know:
- The outbound leg of a blind transfer to an external number is not checked against the account's hours; it stays under the legal hours and the transfer-destination registry.
- While the account is outside its hours, every lead the campaign offers writes a block row about once a minute (the cool-down), so a campaign left running overnight leaves a long evidence trail.
Inbound calls: routes, IVR menus and queues
Three surfaces decide what happens to a call to one of the account's numbers: the route of the number (/v1/inbound-routes), the inbound IVR menus a route can run (/v1/inbound-ivr-menus) and the inbound queues a route or a menu can send the caller to (/v1/inbound-queues). The full request and response schemas are in the API Reference (routes, menus, queues); this section is what an integrator runs into. The dashboard walk-through is Academy module 10.
Build them in this order: queues first, then the menu that names them, then the route that names the menu or the queue.
Routes: /v1/inbound-routes
The number check (core#2414, core#2429) is on core
main, after thev0.129.0tag: a deployment on an earlier version still saves a route for a number that is not the account's.
POST /v1/inbound-routes takes e164 (immutable afterwards) and dest_type, one of four. The number has to be an active DID of the account: register it in /v1/dids before you create the route.
dest_type | What it names | What a call gets |
|---|---|---|
agent | dest_ref = the agent's id (required). | Rings the agent's softphone. With recording: true — the default — the call reaches the agent and is recorded from the moment they answer; if the agent cannot be found, the call is hung up at once. |
ivr | dest_ref = the id of a published, not retired, menu of this account (required). | Runs that menu (see below). |
queue | queue_id = an active queue of this account (optional, see below). | Waits in that queue — only while queuing is on for the account (see capability). |
optout | Nothing. The route is bare. | The calling number goes on the account's own do-not-call list. |
The opt-out number (optout). The calling number is added to the account's own do-not-call list when the call connects, before anything answers and whatever the state of the plan. With an active plan the caller then hears a confirmation the platform composes from the account's seller name — "Your number has been added to the do-not-call list of <seller>. Goodbye." — or a tone when there is no seller name, and the call hangs up. With an inactive plan the call is refused unanswered, and the opt-out is already written. A caller with a hidden or invalid number is refused and nothing is written; an opt-out that could not be written is never confirmed. The route is bare: a non-null dest_ref, recording: true or ai_enabled: true in the same write is 400 invalid, and whatever the write leaves out is set to none / false.
POST /v1/inbound-routes
{ "e164": "+18005550142", "dest_type": "optout" }The menu (ivr). Name the menu's id — the id it was given at version 1, which does not change across versions — so editing the menu never means re-pointing routes: each call hears the version current when it connects. A route that does not name a published menu of this account, or names a retired one, is 400 invalid, and so is re-activating a route whose menu was retired since.
The queue (queue_id). Only on a queue route. An active queue of this account; a retired one, another account's, one that does not exist or a malformed id is 400 invalid, and so is queue_id on any other dest_type. The id is matched regardless of case and read back in lowercase. It is optional — a queue route without it is still accepted — but such a route refuses every call at connect, so treat it as required. A PATCH that moves a route to another dest_type without naming queue_id drops it.
Errors on the route writes:
| You send | You get |
|---|---|
An e164 that is not an active DID of this account (another account's, or one nobody registered), on POST, or on a PATCH that returns a route to active | 422 with the field error e164 — "not a number of this account". Nothing is saved. A route that already exists is never re-judged. |
A dest_ref that is not a canonical UUID, with any dest_type | 400 invalid with the field error dest_ref must be a UUID. Nothing is saved. |
ivr without a published, not retired, menu of this account | 400 invalid on dest_ref. |
queue_id that is not an active queue of this account, or on a type that is not queue | 400 invalid. |
optout with a dest_ref, recording: true or ai_enabled: true | 400 invalid. |
ai_enabled: true on any route | 400 invalid: inbound AI is not available yet, so no AI answers an inbound call. |
Then POST /v1/inbound-routes/{id}/provision pushes the route to the SIP edge, and POST /v1/inbound-routes/{id}/disable turns it off.
Who can write. POST and PATCH /v1/inbound-routes, …/{id}/provision and …/{id}/disable take a dd_ key or an account_admin dashboard login; a member login gets 403 role_forbidden and nothing is saved. Reading (GET) is not affected. See Who can write telephony settings.
Inbound IVR menus: /v1/inbound-ivr-menus
A menu is a tree of nodes. Each node has:
prompt— what the caller hears on entering the node, in your words: at most 2000 bytes, never blank. The platform adds no text of its own.default— mandatory: the action that runs when no key is pressed. Always a terminal action and neveroptout: pressing nothing is not asking to leave the list.entries— at least one: a DTMF key (0–9,*,#) →{"action": …}or{"submenu": {…node…}}.
Terminal actions are queue:<queue_id>, agent:<agent_id>, hangup and optout. At most 5 levels and 64 nodes.
POST /v1/inbound-ivr-menus
{
"name": "Main line",
"menu": {
"prompt": "For payments, press 1. To speak to an agent, press 2. To stop these calls, press 9.",
"default": "queue:6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21",
"entries": {
"1": { "action": "queue:6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21" },
"2": { "action": "agent:0b8d2f4e-5a6c-4d1e-8f3b-2c7a9e1d4b60" },
"9": { "action": "optout" }
}
}
}Publishing. Without menu_id the request publishes a new menu at version 1, whose id is the menu's from then on; with menu_id it publishes the next version of that menu, which becomes the current one. Nothing is edited in place: GET /v1/inbound-ivr-menus/{id} returns the current version and a history with every version. In order, and nothing is written unless all pass: the tree is validated; every queue:<id> must be an active queue of this account and every agent:<id> an active agent of this account; and every prompt is rendered to audio now, not when a call arrives.
On a call. After the plan check the call is answered, the prompt plays and core waits for a key. A key the node does not list does nothing. With no key, the prompt plays once more and, after the second silent window, the node's default runs. The window is counted from when the prompt is sent: 5 seconds plus an estimate of the prompt's length (60 ms per byte, capped at 30 s). A destination that cannot be reached — queuing off for the account, an agent without a seat, a transfer the switch refuses — hangs the call up at once instead of leaving the caller in silence. On an ivr route with recording: true, recording starts when an agent answers; the menu itself is never recorded. The call's record keeps the menu, the version it heard, the keys pressed and where it went.
Errors:
| Operation | You get |
|---|---|
Publish, a tree that breaks the rules above (no default, default: optout, a blank or oversized prompt, a bad key, too deep, too many nodes, an unknown field) | 400 |
Publish, a queue:<id> or agent:<id> that is not an active queue or agent of this account — another account's, one that does not exist and a retired queue read the same | 422 unprocessable, naming them |
| Publish, a prompt that does not render to audio | 503 prompt_render_failed. Nothing was published; the same request may be retried. |
Publish with a menu_id that is not a menu of this account | 404 |
Publish with the menu_id of a retired menu | 409 conflict: a retired menu stays retired — publish a new one. |
POST /v1/inbound-ivr-menus/{id}/retire while an active route still uses the menu | 409 conflict, naming the routes: disable them or point them at another menu first. |
| Retire a menu that is not a current menu of this account (unknown, already retired, another account's) | 404 |
Publish or retire with a dashboard login that is not account_admin | 403 role_forbidden |
Reading (GET of the list, ordered by name, and of one menu) is open to a dd_ key and to any dashboard login; publishing and retiring take a dd_ key or an account_admin login. A widget token (ddw_) gets 403 forbidden on all of them.
Inbound queues: /v1/inbound-queues
| Operation | What it does |
|---|---|
POST /v1/inbound-queues {"name": "Sales"} | Creates an active queue with no overflow. The name is trimmed, 1 to 80 characters, and unique among the account's active queues regardless of case (409 conflict otherwise); a retired queue's name is free again. A queue is never renamed. |
GET /v1/inbound-queues | The active queues, ordered by name — a queue created before names existed reads name: null and comes last — each with its overflow and active_route_count (the active queue routes that name it), plus capability. |
GET /v1/inbound-queues/{id} | One queue, retired ones included (status: retired), plus capability. |
PUT /v1/inbound-queues/{id}/overflow | A call that has waited overflow_max_wait_ms moves to overflow_queue_id. Both fields are required. |
DELETE /v1/inbound-queues/{id}/overflow | Removes the overflow: a call waits as long as it takes. |
POST /v1/inbound-queues/{id}/retire | Retires the queue for good: it leaves the list, cannot be named by a route, a menu or an overflow, and cannot be configured; its history stays. |
GET /v1/inbound-queues/live | How many calls are waiting in each active queue and for how long — see the live read. |
GET /v1/inbound-queues/{id}/live | The same read for one active queue (404 for a retired, missing or another account's queue). |
PUT /v1/inbound-queues/{id}/overflow
{ "overflow_max_wait_ms": 120000, "overflow_queue_id": "6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21" }Errors:
| Operation | You get |
|---|---|
| Create with a name already used by an active queue (any case) | 409 conflict |
| Overflow with a maximum that is not a positive integer, a destination that is this same queue or not an active queue of this account (retired, another account's, missing, malformed), or one that already overflows — directly or through other queues — back to this one | 400 invalid. An overflow cycle is refused. |
| Overflow, retire or read a queue that is not an active queue of this account | 404 (reading one answers retired queues too) |
| Retire while something active names the queue | 409 conflict, naming each of them: the queue is still in use; re-point or disable these first — inbound routes: …; inbound IVR menus: …; queue overflows: …. Disable or re-point them first. |
Create, overflow or retire with a dashboard login that is not account_admin | 403 role_forbidden |
Same readers and writers as the menus: reading is open to a dd_ key and to any dashboard login, writing takes a dd_ key or an account_admin login, and a widget token (ddw_) gets 403 forbidden. Once a queue is retired, a menu that names it can no longer be published (422) and a route cannot name it (400).
capability: whether the queue takes calls
The list and the read of one queue carry the account's capability:
json
"capability": {
"flag": "inbound_queue_stream_enabled",
"enabled": false,
"switches": {
"inbound_queue_stream_enabled": false,
"agent_session_journal_enabled": false
}
}A queue takes calls only when both switches are on for the account, and enabled is true only then. Dialer Digital turns them on per account; there is no API or dashboard control for the account to turn them on, and this read changes neither. While enabled is false, a call to a queue route is not answered and no agent is offered it, whatever the queue's configuration, and a menu key that leads to a queue hangs the call up. switches says which one is off, so a UI can say so instead of offering a queue that does not take calls. The account can create queues, set their overflow and point routes and menus at them in the meantime.
The live read: depth and longest wait
Shipped in core
v0.87.0(core#1526), and in the API Reference.
GET /v1/inbound-queues/live answers, for every active queue of the account, how many calls wait and since when:
json
{
"as_of": "2026-10-04T18:00:00.000000Z",
"stream_enabled": true,
"ready_agents": 3,
"inbound_queues": [
{ "queue_id": "6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21", "name": "Sales", "depth": 2, "oldest_wait_ms": 120000, "wait_bound_ms": 3600000 }
]
}GET /v1/inbound-queues/{id}/live returns the same body with one inbound_queue instead of the list.
depthis the number of calls waiting now: those admitted to the queue that have not been answered, abandoned or moved to another queue. It is recomputed from the queue's append-only event history on every read; there is no counter to drift. A call offered to an agent who has not taken it yet still waits and counts. A call that overflowed to another queue is counted there, where it waits now.oldest_wait_msis how long the longest-waiting call has been waiting, read off the clock atas_of. It isnullwhen nobody waits. It is a live reading for a supervisor, not the measured wait that is written when a call leaves the queue (that one is what history and reports use), so the two are not the same number.- Off is not zero. While
inbound_queue_stream_enabledis off for the account, nothing writes the queue's history, so the answer isstream_enabled: falsewithdepth: nullandoldest_wait_ms: null— never0.ready_agentsfollows the same rule withagent_session_journal_enabled:nullwhen that switch is off. Show «queuing is off», not an empty queue. wait_bound_msis the limit past which a call that never got a final event is not counted, so a crash between joining a queue and leaving it cannot inflate the depth forever. It is the queue'soverflow_max_wait_mswhen it has an overflow, or one hour when it has none.ready_agentsis the number of agents Ready for the whole account, not per queue.
Reading is open to a dd_ key and to any dashboard login; a widget token (ddw_) gets 403 forbidden. There is no event for queue depth on the live floor socket, so a screen that shows this has to poll it; every 5 seconds is enough for a supervisor.
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,status(defaultactive),labels(string array),notes. The tenant does not writeattestation(A|B|C|unknown, the STIR/SHAKEN level the carrier signs): it is accepted and ignored, and only the platform sets it (see Number ownership). The response carries it read-only (schemaDIDincore:openapi/dialer-v1.yaml).GET /v1/dids/{id}→ one DID.Who can write.
POST,PATCHandDELETE /v1/didstake add_key or anaccount_adminlogin; amemberlogin gets403 role_forbidden. TheGETs stay open. See Who can write telephony settings.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.
Who can write telephony settings
Fourteen writes change how calls reach you and leave you (core#2647). They take a dd_ key or an account_admin dashboard login; a member login gets 403 role_forbidden with the same body whatever it sends, and nothing is saved:
| Resource | Writes |
|---|---|
| Numbers | POST, PATCH, DELETE /v1/dids |
| Inbound routes | POST, PATCH /v1/inbound-routes, …/{id}/provision, …/{id}/disable |
| Office trunks | POST, PATCH /v1/carriers, …/{id}/provision, …/{id}/revoke (a company-wide trunk already took account_admin alone) |
| Widget sites | POST /v1/widget_origins, DELETE …/{id}, POST …/{id}/enable (see Widget embed) |
The GET of each is not affected. The rule sits behind the per-account setting telephony_config_admin_only, on by default and set by the platform, not by you. Whether every dashboard user should keep this reach (for example a supervisor who is not account_admin) is still an open product decision on our side: today they lose these writes, and turning the setting off for an account restores the earlier behaviour. Do not build on either answer as permanent.
Number ownership
POST /v1/dids registers a number as self-declared: it does not prove you own it. Each DID carries verified_at and verification_method (house, otp or admin); both are null until ownership is proved, and house numbers (platform inventory) are born verified. Only the platform verifies a customer number, with POST /v1/admin/tenants/{id}/dids/{did_id}/verify (adminVerifyDid), which is also the only way attestation is ever written. The tenant cannot verify its own numbers. (Source: DID schema and adminVerifyDid in core:openapi/dialer-v1.yaml; core:changelog.d/core-2415.md.)
What an unverified number does depends on the per-tenant key did_ownership_proof, which the platform sets:
| Value | An unverified customer number… |
|---|---|
off | is used as before; nothing is checked. |
observe (default) | is still used; the platform only counts the use. |
enforce | is refused: a campaign does not dial or send from it, and POST /v1/sms/messages answers 422 did_unverified. |
Two places show it in enforce:
POST /v1/sms/messageswith afrom(or a provider default sender) that is acustomernumber never verified →422 did_unverified.send_unavailable_reasoninGET /v1/campaigns/{id}/stats(and thecampaign.statsframe) isdid_unverifiedwhen the only active numbers the campaign would send from are registered but have noverified_at. Ask support to verify them. (Source:sendSmsrequest body and thesend_unavailable_reasonenum incore:openapi/dialer-v1.yaml.)
CSV exports
Every CSV that core returns on /v1 is written through one function, Dialer.CSV, and a cell whose first character is =, +, -, @, a tab, a carriage return, a line feed, or the full-width =, +, -, @ is neutralised: it is delivered with a ' in front, so Excel and Sheets read it as text and never run it as a formula. The guard runs before the RFC 4180 quoting, so a hostile cell that also holds commas or quotes comes out both prefixed and quoted. It has no exception for numbers: an unmasked E.164 number comes out as '+1312…, and a negative value as text. The prefix is part of the cell; strip it if you parse the file. The same applies to the CSV that the browser builds in the console. See the report builder for the exact behaviour of ?format=csv there.
Bulk recording export: 422 export_too_large
POST /v1/recording-exports takes the same filters as GET /v1/recordings and answers 202 with the job in export (export.job_id). When the filters match more recordings than the platform row limit allows, the request is refused with 422 and code: "export_too_large"; no job is created. The error adds two integers, max_rows (the limit) and matching_rows (how many recordings the filters match). The export is rejected, never cut short: a finished export always holds every matching row. Narrow the filters and send it again. The console reads both figures and shows them.
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.
json
{
"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:
json
{
"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). Without campaign_ids (or with it empty) the report covers every campaign of the tenant, with one person-level total; non-UUID values or more than 100 ids → 400. window=7d|30d|90d|all answers the same totals / by_campaign over a window relative to now, served from a short-lived snapshot derived from the CDR: the response adds window and as_of (the instant the numbers were computed), and a snapshot older than the server's max_age (default 60 s) is never served — the report is computed live instead. window is exclusive with from/to and with granularity=day (422).
json
{
"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.
GET /v1/stats/abandonment
The abandonment report of every campaign of the tenant in one response — the same report GET /v1/campaigns/{id}/abandonment gives per campaign. { "as_of": "…", "campaigns": [ … ] }, one report per campaign ordered by campaign name. Like window= on penetration it is served from a short-lived snapshot: as_of is when it was computed, and a snapshot older than max_age (default 60 s) is never served. The per-campaign route stays live and is the record; this one is the overview.
GET /v1/stats/contact-windows
When do people answer? The contact rate for each local hour of the consumer — the hour on the wall clock where they live, not the hour on your server — plus the best two-hour window to call and how many phone numbers you hold per person.
Query params: from/to (RFC 3339, default the last 30 days; the window is [from, to)), optional campaign_id. A malformed date or campaign_id → 400; from not before to → 422; a window longer than 90 days → 422; a campaign_id that is not one of your campaigns → 404.
json
{
"from": "2026-09-04T00:00:00Z",
"to": "2026-10-04T00:00:00Z",
"campaign_id": null,
"basis": "consumer_local_hour",
"min_attempts": 50,
"hours": [
{ "hour": 0, "attempts": 0, "contacts": 0, "contact_rate": null },
"... 24 entries, hour 0-23, always all present ...",
{ "hour": 10, "attempts": 212, "contacts": 61, "contact_rate": 0.2877 }
],
"unknown": { "attempts": 14, "contacts": 3, "no_zone": 9, "ambiguous_zone": 5 },
"best_window": { "start_hour": 17, "end_hour": 19, "attempts": 388, "contacts": 131, "contact_rate": 0.3376 },
"best_window_reason": null,
"phones_per_person": { "persons": 940, "phones": 1411, "average": 1.5 }
}- An attempt is a call the platform placed itself (not inbound, not an external-transfer leg, not a call that never went out). A contact is an attempt that ended
answered_human;contact_rateiscontacts / attempts, andnullfor an hour with no attempts. - The hour comes from the time zone the quiet-hours gate used when it decided the call was legal. A call whose number spans several zones (or whose declared zone disagrees with it) has no knowable local hour: it is counted in
unknown(ambiguous_zone, orno_zonewhen no zone was recorded) and never spread over the hours. best_windowis the pair of consecutive hours (start_hour,end_hour = start_hour + 2, never wrapping past midnight) with the highest combined rate, and each of the two hours must hold at leastmin_attemptsattempts (the tenant settingcontact_window_min_attempts, default 50). Ties go to the earlier hour. Without enough data it isnullandbest_window_reasonsays why —insufficient_attemptsorno_resolved_attempts— instead of an hour picked from noise.phones_per_personis the average number of distinct phone numbers per person among active accounts (notpaid,settledorclosed); a person with no phone counts as zero. It covers the whole tenant and ignoresfrom,toandcampaign_id.averageisnullwhen there are no active accounts.
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).
json
{"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.
json
{"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).
Archive a defense packet and verify its time-stamp offline
Release status: the three archive routes are published in core
v0.88.0and are in the generated API Reference.
Archiving is a separate, explicit operation. An empty POST /v1/debts/{id}/defense-packet/archives builds the same ZIP as GET /v1/debts/{id}/defense-packet?format=zip, time-stamps the SHA-256 of those ZIP bytes, writes them to the archive store, reads the retention state back and only then records one append-only attestation. If the store, the time-stamp authority or that read-back fails, the response is 503 and no attestation row is written. With archiving disabled, all three archive routes answer 404. (Contract: the archiveDefensePacket operation in core:openapi/dialer-v1.yaml.)
The archived object is therefore the ZIP litigation bundle described above: its packet.json carries the debt, contacts, call attempts, conversations, gate refusals, consent and DNC evidence, cease-and-desist records, promises, callbacks, supervision actions, policy versions and the packet's own integrity object. (Contract fields and ZIP response: the getDefensePacket operation's ZIP response in core:openapi/dialer-v1.yaml.)
GET /v1/debts/{id}/defense-packet/archives returns the attestations newest first and does not inline the RFC 3161 token. Each row identifies the archived bytes with artifact_sha256, carries the packet's integrity_digest, and records the store, retention result and TSA time. (List operation: the listDefensePacketArchives operation in core:openapi/dialer-v1.yaml; row fields: the DefensePacketArchive schema.)
Read the retention result as two mechanisms, without upgrading one into the other:
- Object retention:
retention_modeisCOMPLIANCEorGOVERNANCE;version_ididentifies the stored object version andretain_untilis the time the store confirmed. These are returned store facts; the API does not claim thatGOVERNANCEhasCOMPLIANCEsemantics. (Fields: theDefensePacketArchiveschema'sversion_id,retention_mode, andretain_untilproperties incore:openapi/dialer-v1.yaml.) - Bucket-rule retention:
retention_modeisbucket_lock. In that caseversion_idandretain_untilarenull, and the bucket rule governs. Call this configured retention, neverCOMPLIANCEor immutable retention. (Fields: theDefensePacketArchiveschema'sversion_id,retention_mode, andretain_untilproperties incore:openapi/dialer-v1.yaml.)
Download and verify without the platform
You need the debt id, the archive id from the list operation, and the TSA root certificate from a source you trust independently. If the TSA uses intermediate certificates, save its published chain as tsa-intermediates.pem too. The attestation operation returns three things: the row as attestation, a base64 DER TimeStampResp as tsr, and a short-lived pre-signed ZIP URL under artifact.url, with its deadline in artifact.expires_at. (Response contract: the getDefensePacketAttestation operation in core:openapi/dialer-v1.yaml.)
Fetch the kit and immediately download the ZIP while the URL is valid.
bashBASE_URL="https://api.example.com" DEBT_ID="<debt-uuid>" ARCHIVE_ID="<archive-uuid>" TOKEN="<bearer-token>" curl --fail --silent --show-error \ -H "Authorization: Bearer ${TOKEN}" \ "${BASE_URL}/v1/debts/${DEBT_ID}/defense-packet/archives/${ARCHIVE_ID}/attestation" \ --output kit.json jq -er '.tsr' kit.json | base64 --decode > artifact.tsr curl --fail --location --silent --show-error \ "$(jq -er '.artifact.url' kit.json)" \ --output artifact.zipCompare the downloaded bytes with the digest in the attestation. A mismatch means this ZIP is not the artifact named by the kit; stop there. The field is the SHA-256 of the archived ZIP bytes and the digest the authority time-stamped. (Field contract: the
DefensePacketArchiveschema'sartifact_sha256andintegrity_digestproperties incore:openapi/dialer-v1.yaml.)bashexpected="$(jq -er '.attestation.artifact_sha256' kit.json)" actual="$(sha256sum artifact.zip | awk '{print $1}')" test "${actual}" = "${expected}"Verify the DER response against that same digest and the TSA trust chain.
bashopenssl ts -verify \ -digest "${actual}" \ -in artifact.tsr \ -CAfile tsa-root.pem \ -untrusted tsa-intermediates.pemIf the TSA has no intermediate certificates, omit the final
-untrusted tsa-intermediates.pemline. Acceptance binds the ZIP digest to the RFC 3161 response under the supplied TSA chain. The contract also exposes the authority's signed time asattestation.tsa_gen_time. (Verification contract and time field: thegetDefensePacketAttestationoperation's description and theDefensePacketArchiveschema'stsa_gen_timeproperty.)
This check does not independently prove that the store still retains the object. It proves the time-stamp over these ZIP bytes; retention remains the store-reported retention_mode, retain_until and version_id, with the bucket_lock limits stated above. (Attestation semantics: the DefensePacketArchive schema's retention properties in core:openapi/dialer-v1.yaml.)
PUT /v1/recording-policy: the retention ceiling
PUT /v1/recording-policy replaces the tenant's recording policy (enabled, retention_months, channels, format). A dd_ key or an account_admin login may write it; a member login is 403 role_forbidden. retention_months has a floor and a ceiling, and both are 422 unprocessable:
- Floor: the tenant's effective retention floor, from its declared business types (see Retention by business type).
- Ceiling: what the plan includes,
max(3, floor)months, plus 12 months for each block of extra retention the tenant has bought. With no block bought, the ceiling is the floor itself. The message names the ceiling:retention_months must be at most N months: the retention your plan includes plus the 12-month blocks you have bought.
A policy already stored above its ceiling keeps its value: it may be saved again at that value or lower, never raised. Blocks are recorded by the platform when they are sold; there is no field on this API to buy one. The ceiling is enforced while the platform's recording_retention_cap setting is on, and it is on by default. Source: core:lib/dialer/recordings/policy.ex (max_retention_months/2) and core:lib/dialer/billing/plan.ex.
Storage measurement. On the first day of each month the platform records, per tenant with stored recordings, one storage_gb_month event with the GiB held at the close of the previous month. It is a measurement only: the event carries a price of 0 and an amount of 0. It is not a line item of GET /v1/billing/summary and no API endpoint returns it. Source: core:lib/dialer/recordings/storage_meter_job.ex.
Record the consent you hold
The consent gate and the artificial-voice gate (AI, TTS, IVR, survey) read the consent recorded for each number. Until these routes, the only way a consent got there was the consumer answering START by SMS. These three routes let you record the consent you already hold, with its proof. Contract: /v1/consents/{phone} and /v1/consents/import in openapi/dialer-v1.yaml of core.
| Route | What it does |
|---|---|
PUT /v1/consents/{phone} | Records the consent of one number. Repeating the call replaces the row (upsert by number). |
DELETE /v1/consents/{phone} | Revokes the consent. It always applies, also to a number with no consent on file, and it is idempotent: from then on the gate refuses the number (consent_revoked) until a consent dated after the revocation is recorded. |
POST /v1/consents/import | Records many numbers from a text/csv body. |
- Who can write. A
dd_key or anaccount_admindashboard login. Amemberlogin gets403 role_forbiddenon the three routes and nothing is saved; a widget token or a suspended tenant gets403 forbidden. - What a write carries.
channel(voice,smsorany),consented_at(ISO 8601 with an offset),source(up to 200 characters: where the consent was obtained) andevidence_ref(up to 2 000 characters: where the proof lives — a recording id, a signed form, a URL). The four are required;text_version(up to 200) is optional. Withoutevidence_refthe consent is not accepted. The platform stores that reference as you give it: it does not open it or verify it. - The number travels in the path (E.164, with the
+as%2Bor not, or a bare NANP number), so it reaches the access logs of any proxy in front of the API. Responses never return it whole:***0100. - The date is validated.
consented_atcannot be in the future (422) nor later than the day the number was first registered in your contacts (422). A number whose consumer revoked takes a new consent only ifconsented_atis later than the revocation; otherwise409 consent_revoked. If the revocation carries no date, only the consumer lifts it, withSTART. A consent you obtained after importing the number cannot be recorded with its true date through this route. - History. Each write and each revocation is kept in the append-only history of opt-outs and sealed in the compliance chain, in the same transaction. The row of the number is "last write wins": a later revocation removes the proof from the row, and the history is where it survives.
- CSV import. One row per number:
number,channel,consented_at,source,evidence_ref[,text_version]. The header is optional; blank lines and lines starting with#are skipped; a field with a comma goes in double quotes. Limits: 1 000 000 bytes of body (413 payload_too_large), 1 000 data rows, and at most 100 entries inrejected(rejected_countcarries the true total). Another content type is415. Each row is validated like the single write. The response is{received, recorded, rejected_count, rejected: [{line, value, reason}]}with the value masked: a200does not mean every row was recorded;rejected_countis authoritative. A refused row leaves its number without consent, so the gates keep blocking it. Imports are throttled like the debt imports (429).
bash
curl -X PUT "https://api.dialerdigital.example/v1/consents/%2B13125550100" \
-H "Authorization: Bearer $DD_API_KEY" -H "Content-Type: application/json" \
-d '{"channel":"voice","consented_at":"2026-01-15T14:30:00Z","source":"web_form",
"evidence_ref":"https://forms.example.com/submissions/8841","text_version":"tcpa-v3"}'
# -> {"consent":{"number":"***0100","status":"granted","channel":"voice", ...}}What this does not do, said as it stands:
- A consent does not open the other gates: DNC, calling hours, Reg F and the rest keep deciding every call. It does not change a number's line type either.
- No gate reads
channelyet: a consent recorded assmsalso opens voice, asSTARTalready does. - The minimum proof the platform requires and how a
landlineyou declare at import is treated are still undecided; today a declaredlandlinestill relaxes the consent check. Do not rely on either point as final. - The defense packet does not yet return
evidence_refortext_versionper event: the proof is in the history and in the seal.
Company-wide opt-out by SMS. A STOP received through a company-wide SMS provider (one with no site) is written to the internal do-not-call list and revokes consent in every site of the account. A STOP through a site provider stays with that site. It never crosses to another account. See SMS messaging.
POST /v1/privacy/erasure-requests: erase one person
Asks the platform to anonymize the personal data it holds about one person in your account, given by phone (E.164) or consumer_ref (exactly one of them). It takes a dd_ key or an account_admin dashboard login; a member login is 403 role_forbidden.
json
{"phone": "+13055550123", "dry_run": true}Dry run (the default). With dry_run absent or true the request reads, classifies and records the request, and changes no row. The report lists every category of data found (found, would_anonymize), what is retained with the reason and the date until which it is kept, and the copies outside the main database in derived_copies. Use it first: it is how you see what an erasure would do before it does it.
Execution (dry_run: false). The same request anonymizes the person for real. In one pass it:
- purges the audio, transcript, segments and summary of the person's recordings that are past their retention and not under
legal_hold; - empties the transcript of the person's expired AI conversations, only when
ai_conversation_purge_enabledis on for the account; - anonymizes the personal content (numbers, last-4 digits, names, account numbers, message bodies, notes, contact emails) of every other record older than your account's records floor, which is never less than 3 months. The rows stay in place; only what identifies the person goes;
- erases every email channel address of the person, whatever its age (no records floor): it then reads
address: nullwithineligible_reason: address_erased(email channel); - deletes the contactability scores of the person's numbers, except one under durable quarantine, and empties the payload of their settled webhook deliveries (a delivery still pending is kept);
- blanks the last-4 digits in the analytic copy of the call records (
derived_copiesreportsmutated,nothing_to_mutateornot_configured). If that copy cannot be changed, the request fails with503 erasure_failedand nothing is anonymized: repeat it.
What stays sealed. The execution writes a tombstone in the account's compliance audit chain, in the same transaction as the anonymization, and returns its position as chain_seq. The chain keeps verifying: rows are anonymized, never deleted. The tombstone and the request record hold the person only as an HMAC, never the phone number or the reference.
What it never touches. Suppression (do-not-call listings, cease-and-desist records, revoked consents and the suppression history), recordings under legal_hold or inside their retention, and any record younger than the floor. Those appear under retained in the report. Records within the floor can be erased later: ask again by consumer_ref once the floor has passed, because after the contact is erased the phone number no longer finds the person.
The execution is off by default. Two settings gate the route, and both are off until the platform operator turns them on; you cannot enable them through this API:
| Request | Setting | If it is off |
|---|---|---|
| Dry run | privacy_erasure_enabled (per account) | 403 privacy_erasure_disabled, nothing written |
dry_run: false | privacy_erasure_execute_enabled | 403 privacy_erasure_execution_disabled, nothing written |
Do not build a flow that assumes the execution is available: ask the platform operator whether it is on for your account.
The response is 202 with erasure_request: its id, dry_run and the report (categories, derived_copies, cutoff, records_floor_months, chain_seq and execution, which is simulated or executed). Each request leaves an append-only record; if it cannot be written, no report is returned (503 audit_unavailable). Repeating a request is safe. Source: the createErasureRequest operation in core:openapi/dialer-v1.yaml.
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.
The billing seat minimum is per plan (Dialer.Billing.Plan.seat_minimum/1, macro-tasks#870): starter 1, growth 1, scale 3, enterprise 25. The allowance of the row is per account, not per seat: with 3 or more billable seats the account gets the row's allowance as is; with 1 or 2 it gets seats ÷ 3 of it (minutes, SMS segments and AI minutes truncated down, SMS to whole segments, included DIDs truncated with a minimum of 1). The summary, the month close and the spend brake read the same computation.
What IS account-wide code (Dialer.Billing.Plan): 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):
json
{
"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).
Past the allowance: the excess is drawn from the prepaid balance
When a subscription tenant exhausts an allowance, work continues and only the excess is charged to its prepaid balance, at the price stamped on the usage event itself (the published catalog price, never a constant). The window is the UTC calendar month, the same one the month close uses; the pools are voice minutes (outbound + inbound), SMS segments and AI usage. Fail-closed: with no balance available, no rate, or no way to decide, the spend brake stops the traffic with plan_exhausted, as before. A call authorized inside the allowance that crosses the limit can leave the balance slightly negative by that fraction.
Pay as you go
The entry mode is one seat with all consumption drawn from the prepaid balance and no allowance. GET /v1/billing/tiers (a dd_ key or an account_admin login) carries it as the pay_as_you_go object next to tiers: scheme (prepaid), tier (starter), seats (the plan's seat minimum), seat_price_usd (read from the catalog row), billed_from (balance) and an included block that states the absence of the three allowances on purpose (minutes: "0", sms_segments: 0, ai_minutes: "0"). The object is null when the entry tier is not on sale.
GET /v1/billing/cost?group_by&from&to
The gross cost of the usage ledger, grouped by one dimension per request: group_by=campaign, carrier or agent. It answers "which campaign, carrier or agent is spending right now"; it is not the invoice. The included allowance, the volume tier, the design-partner discount and the monthly minimum are applied on the invoice and are not split across groups: the reconciliation block puts gross_total next to the lines of GET /v1/billing/summary over the same window and lists what is not allocated.
- Behind a flag, off by default. Only tenants with
billing_cost_enabledON can read it (a platform operator writes the flag). With it OFF every caller, of any role, gets the same404 not_found, as if the route did not exist. - Who. A
dd_API key or a dashboard login with theaccount_adminrole. Any other login gets403 role_forbidden, the same body whatever it sends. - Window.
fromandtoare mandatory RFC 3339 instants,from < to, at most 31 days apart (400 bad_requestotherwise), half-open like/v1/billing/summary. The ledger is keyed by UTC day, so an intra-day window sums its whole UTC days (ledger_dayssays which). - Nothing is dropped. Every ledger row lands in exactly one group, or in the bucket with
key: nullandunattributed: true. Currencies are never mixed: each group and each total carries its own.billed_byisplatform(house trunk),tenant_carrier(the customer's own carrier) ornull. - When it cannot answer. If part of the window is not closed in the daily rollup (and is not one of the two most recent UTC days), the answer is
503 unavailable; if the groups did not add up to the total,500 cost_unreconciled. A partial number is never published.
json
{
"group_by": "campaign",
"from": "2026-10-01T00:00:00Z",
"to": "2026-10-08T00:00:00Z",
"ledger_days": { "from": "2026-10-01", "to": "2026-10-07" },
"sources": { "rollup": 5, "direct": 2, "empty": 0 },
"groups": [
{ "key": "<campaign-uuid>", "unattributed": false, "currency": "USD", "billed_by": "platform", "amount": "41.2500", "events": 310 },
{ "key": null, "unattributed": true, "currency": "USD", "billed_by": null, "amount": "8.7500", "events": 52 }
],
"gross_total": [
{ "currency": "USD", "amount": "50.0000", "events": 362 }
],
"reconciliation": {
"basis": "ledger_gross",
"groups_sum": [
{ "currency": "USD", "amount": "50.0000", "events": 362 }
],
"invoice": {
"status": "available",
"currency": "USD",
"total_usd": "870.0",
"lines": [
{ "kind": "seats", "amount_usd": "595", "gross_amount_usd": null },
{ "kind": "house_minutes", "amount_usd": "0", "gross_amount_usd": "41.2500" }
]
},
"not_allocated": ["included_allowance", "volume_tier", "discount", "monthly_minimum"],
"note": "gross_total is the sum of the usage ledger, not the amount billed; the included allowance, volume tier, discount and monthly minimum are applied on the invoice and are not split across groups"
}
}The example is the one in the vendored specification (billingCost in openapi/dialer-v1.yaml), with the campaign UUID of the first group replaced by a placeholder; the schema is BillingCost in the API Reference. In the console, the same read backs the Derived Cost card on Billing & Plan (see Module 17).
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.