Skip to content

REST API

The generated API Reference is the always-fresh source

This page is a hand-written narrative of the /v1 surface as of core v0.9, kept for its guidance and context. For the complete, guaranteed-current endpoint reference — generated at every build from core's drift-gated OpenAPI spec (v0.12.x, including SMS, DIDs, carriers and inbound routes) — use the API Reference.

Reference for the core control-plane API (/v1), as implemented in core v0.9 (the billing & account surface). All endpoints except GET /healthz require an API key — see Authentication.

Conventions (error envelope, request IDs, tenancy, timestamps) are described in the Developers overview.

To exercise the surface instead of reading it, import the Postman collection — 27 folders over /v1, vendored from the integration harness with the local credential stripped.

Endpoint index

MethodPathPurpose
GET/healthzLiveness probe (unauthenticated).
GET/v1/meThe authenticated tenant (incl. tier, voice_ai_enabled).
PATCH/v1/tenantAccount self-edit. name ONLY — plan, seats and the add-on are platform surface.
GET/v1/tenantsOne-element list: your own tenant.
GET/v1/tenants/{id}Your tenant, or 404.
GET/v1/api_keysList API keys.
POST/v1/api_keysMint a key (plaintext shown once).
DELETE/v1/api_keys/{id}Revoke a key.
GET/v1/campaignsList campaigns.
POST/v1/campaignsCreate a campaign (state draft).
GET/v1/campaigns/{id}Get one campaign.
PATCH/v1/campaigns/{id}Update config (never state).
POST/v1/campaigns/{id}/startrunning + spawn the dial-loop runner.
POST/v1/campaigns/{id}/resumeAlias of start.
POST/v1/campaigns/{id}/pausepaused, stop the runner.
POST/v1/campaigns/{id}/stopcompleted (terminal).
POST/v1/campaigns/{id}/archivedraft|completedarchived.
GET/v1/campaigns/{id}/statsLive runner + pacing counters.
GET/POST/v1/campaigns/{id}/rewind/previewRewind dry-run.
POST/v1/campaigns/{id}/rewindRe-queue matching debts.
POST/v1/debts/importBulk JSON lead import (idempotent).
GET/v1/debts/{id}Debt + contacts.
GET/v1/debts/{id}/call_attemptsFull durable CDR history of one debt.
GET/v1/debts/{id}/defense-packetSealed litigation evidence bundle (JSON or ZIP).
GET/v1/call_attemptsFiltered, keyset-paginated CDR query.
POST/v1/call_attempts/{id}/dispositionTyped agent/AI outcome (+ promise for PTP).
GET/v1/promisesList promises.
PATCH/v1/promises/{id}pendingkept|broken|cancelled.
POST/v1/callbacksSchedule a consumer-agreed redial.
GET/v1/callbacksQueue view / runner due feed.
POST/v1/callbacks/{id}/completependingdone.
POST/v1/callbacks/{id}/cancelpendingcancelled.
POST/v1/callbacks/{id}/misspendingmissed.
GET/v1/agentsRoster + live presence.
POST/v1/agentsCreate an agent seat.
GET/v1/agents/{id}One agent + presence.
POST/v1/agents/checkinSeat goes available (by sip_extension; optional device_mode).
POST/v1/agents/checkoutSeat goes offline.
POST/v1/supervision/calls/{call_id}/listenSilent monitor a live call.
POST/v1/supervision/calls/{call_id}/whisperCoach the agent (audible to the agent only).
POST/v1/supervision/calls/{call_id}/bargeThree-way (both sides hear the supervisor).
POST/v1/supervision/calls/{call_id}/takeoverTransfer the debtor to the supervisor.
GET/v1/didsDID registry (filter by status).
POST/v1/didsRegister 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}/healthCDR-computed answer/short-call health.
GET/v1/stats/summaryToday's operational counters.
GET/v1/stats/promisesPTP pipeline summary.
GET/v1/stats/penetrationPerson-level penetration analytics.
GET/v1/stats/blockedDurable blocked-dial rollup (by-gate + daily).
GET/v1/stats/ai-usageMetered voice-AI minutes (JSON or CSV).
GET/v1/reports/violations-preventedPackaged compliance artifact (JSON or CSV).
GET/v1/billing/summaryInvoiceable 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 (there are no users on this API):

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.


Campaigns

Campaign lifecycle: draftrunningpausedcompleted (terminal) → archived. draft can also be archived directly. Lifecycle is explicitstate 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 name409 conflict.

GET /v1/campaigns/{id} / PATCH /v1/campaigns/{id}

Fetch / update the config fields above. PATCH returns 200 {"campaign": {...}}; state is not an accepted field — sending it fails the request with 400 bad_request naming the key. Use the lifecycle endpoints below.

Lifecycle: start / resume / pause / stop / archive

POST /v1/campaigns/{id}/start
POST /v1/campaigns/{id}/resume    # alias of start (paused -> running)
POST /v1/campaigns/{id}/pause
POST /v1/campaigns/{id}/stop
POST /v1/campaigns/{id}/archive
  • start persists running FIRST, then spawns the dial-loop runner. Idempotent if already running. Optional runner opts in the body, validated fail-closed (400 bad_request on 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_configured ya no se acepta aquí: el schema es cerrado y enviarlo es 400 bad_request. La divulgación de voz artificial de la vía NO-IA se almacena una vez con PUT /v1/artificial-voice-disclosure y se resuelve por intento, en vez de afirmarse en el arranque.

  • pause persists paused and stops the runner; in-flight calls finish via the CDR pipeline.

  • stop persists completed (terminal) and stops the runner.

  • archive only from draft or completed.

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.

Campaign rewind (operator re-queue)

Re-queues exhausted leads so the runner treats them as never-attempted. The CDR is untouched — append-only, Reg F counters keep counting.

Preview (dry-run)

POST /v1/campaigns/{id}/rewind/preview        # filters in the JSON body
GET  /v1/campaigns/{id}/rewind/preview?dispositions=no_answer,busy&max_attempts=3&last_attempt_older_than_days=7
json
{
  "matching": 184,
  "excluded_by_compliance": 12,
  "debt_ids": ["..."]
}

excluded_by_compliance = matching debts the engine would block right now (Reg F / DNC / quiet hours). It is informational — the gates re-enforce at every originate anyway.

Execute

POST /v1/campaigns/{id}/rewind
json
{
  "dispositions": ["no_answer", "busy"],
  "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), max_attempts (count of original attempts), last_attempt_older_than_days.
  • keep_scheduled_callbacks defaults to true; false cancels the campaign's pending callbacks for the rewound debts.
  • Sets a per-debt requeued_at watermark and resurrects closedopen. Debts in paid / settled / disputed are never rewound.
  • amd_verdicts is reserved422 unprocessable (fail-closed, never silently ignored). Bad filter types → 400.

Debts / leads

POST /v1/debts/import

Bulk JSON import: idempotent, per-item fail-soft, max 10 000 items per request. Debts upsert on external_ref, contacts upsert on phone_e164.

json
{
  "debts": [
    {
      "external_ref": "demo-1001",
      "consumer_ref": "consumer-1001",
      "debt_type": "other",
      "account_number": "4111-XXXX",
      "amount_cents": 50000,
      "currency": "USD",
      "campaign_id": "c1a2b3c4-d5e6-7f80-91a2-b3c4d5e6f708",
      "contacts": [
        {
          "phone_e164": "+13125551001",
          "line_type": "landline",
          "timezone": "America/Chicago",
          "us_state": "IL",
          "city": "Chicago",
          "is_primary": true
        }
      ]
    }
  ]
}
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_e164 must be E.164 (enforced by a database CHECK); line_typemobile|landline|voip|unknown.
  • debt_typeother|student_loan.
  • Failures come back per item: {"failed": [{"index": 3, "error": "..."}]} — the rest of the batch still imports.
  • CSV multipart upload is not implemented — the dashboard's CSV mapper converts client-side and posts this JSON.

GET /v1/debts/{id}

json
{
  "debt": {
    "id": "d0e1f2a3-...",
    "tenant_id": "0a1b2c3d-...",
    "campaign_id": "c1a2b3c4-...",
    "external_ref": "demo-1001",
    "consumer_ref": "consumer-1001",
    "debt_type": "other",
    "account_number": null,
    "amount_cents": 50000,
    "currency": "USD",
    "state": "open",
    "last_conversation_at": null,
    "created_at": "2026-06-01T09:00:00Z",
    "updated_at": "2026-06-01T09:00:00Z",
    "contacts": [
      {
        "id": "e1f2a3b4-...",
        "debt_id": "d0e1f2a3-...",
        "consumer_ref": "consumer-1001",
        "phone_e164": "+13125551001",
        "line_type": "landline",
        "timezone": "America/Chicago",
        "us_state": "IL",
        "city": "Chicago",
        "is_primary": true
      }
    ]
  }
}

GET /v1/debts/{id}/call_attempts

The full durable history of one debt — original attempts plus compensating corrections, in one list. Shape per row as in CDR query.


CDR query

GET /v1/call_attempts

The append-only call-attempt table is the platform's CDR truth.

Query parameters:

ParamTypeNotes
campaign_id, debt_id, agent_idUUIDFilters. Non-UUID values → 400.
dispositionstringanswered_human | abandoned | busy | no_answer | canceled | rejected | failed.
from, toRFC 3339Bounds on started_at.
limitintDefault 100, max 1000.
cursorstringOpaque 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_snapshot carries the frozen evidence of the compliance decision that allowed the dial — your audit trail, immune to later data changes.

Pagination: the keyset pattern

List endpoints that can grow unbounded (/v1/call_attempts) use keyset cursors, not offsets:

  1. Request with limit (≤ 1000). The response includes next_cursor when the page was full, null when you reached the end.
  2. Pass cursor=<next_cursor> (verbatim, it is opaque) with the same filters to get the next page.
  3. Repeat until next_cursor is null.
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. Smaller list endpoints (promises, callbacks) take limit (max 1000) without a cursor for now.


Typed dispositions

POST /v1/call_attempts/{id}/disposition

Records an agent/AI-entered outcome for a finished attempt as a compensating CDR record — the table stays append-only; nothing is ever overwritten.

{id} must be an original row (corrects_id: null); corrections and unknown ids are 404.

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

The promise-to-pay contract (422s)

The typed contract is bidirectional and fail-closed:

You sendResult
"disposition": "promise_to_pay" with a valid promise payload201 — correction + promise commit in one transaction; the debt's lead state advances to promise_to_pay (still dialable).
"disposition": "promise_to_pay" without promise422 unprocessable — a PTP without terms is not a PTP.
Any other disposition with a promise payload422 unprocessable — promises only attach to promise_to_pay.

Promise payload rules: amount_cents > 0; currencyMXN | 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_phone must already be a contact of the debt — the compliance gates need its line/locale data → 422 unprocessable otherwise.
  • scheduled_at must be ≥ 10 minutes in the future → 422 otherwise; non-RFC-3339 → 400.
  • Unknown debt → 404. campaign_id defaults 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 AND scheduled_at <= now, ordered by priority then time. The campaign runner dials due callbacks before fresh leads and completes them itself once the attempt row is durable.
  • Otherwise — the queue view filtered by status, soonest first.

POST /v1/callbacks/{id}/complete|cancel|miss

Status machine: pending → done | cancelled | missed (terminal). Re-transition → 409 conflict. Returns 200 {"callback": {...}}.


Agents (seats)

Agent presence states: offline | available | ringing | on_call. Presence is driven by checkin/checkout and the dialer itself — there is no presence-PATCH endpoint. (A wrap_up state does not exist yet; see Roadmap.)

GET /v1/agents

Durable roster merged with live presence:

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; roleagent|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. 404 unknown/inactive extension; 409 conflict if the seat is on a call. Optional device_mode (browser | external) persists how this seat is bridged — browser seats are hunted at the WSS softphone first, external seats (Zoiper / desk phone) go straight to the registrar; omitted keeps the stored value, anything else → 422.
  • checkout — only an available seat may leave (409 otherwise — fail-closed: you cannot pull a seat out from under a live call).

Both return 200 {"agent": {...}} (the agent JSON carries device_mode). Presence transitions also stream as agent.presence WebSocket events.


Supervision (live-call monitoring)

POST /v1/supervision/calls/{call_id}/{listen|whisper|barge|takeover} — body {"supervisor_ext": "2000"}. {call_id} is the live call's FreeSWITCH uuid (= call_attempts.call_uuid, also the call_id of the call.* floor events).

  • listen — hear both sides, audible to nobody.
  • whisper — audible to the AGENT leg only (coaching).
  • barge — three-way (both sides hear the supervisor).
  • takeover — transfer the debtor leg to the supervisor and drop the agent.

listen|whisper|barge ring the supervisor's REGISTERED endpoint (their checked-in softphone or SIP device — answer within 20 s to join). One active mode per supervisor per call: switching mode is a new call (the previous leg hangs up, its audit row flips to ended, a supervision.ended event fires).

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).


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 by e164; bad status → 400).

  • POST /v1/dids201 {"did": {...}}. Fields: e164 (required, E.164, unique per tenant → 409 on duplicates), npa (derived from +1 numbers when omitted), us_state, attestation (A|B|C|unknown — the STIR/SHAKEN level the carrier signs), status (default active), labels (string array), notes.

  • GET /v1/dids/{id} → one DID.

  • PATCH /v1/dids/{id} — metadata/status only; e164 is immutable (a different number is a different DID; CDR history is never re-attributed).

  • DELETE /v1/dids/{id}retires (status retired), idempotent. Rows never leave the registry: CDR from_number provenance must keep resolving for the Defense Packet.

  • GET /v1/dids/{id}/health?from&to (RFC-3339 over started_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 are null when the denominator is zero (never invented); reputation.* are explicit null until an external feed is contracted.


Stats

GET /v1/stats/summary

Today's operational counters — computed from the append-only CDR (UTC day window) + the live seat registry. Reconstructible truth, not cached state.

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_30d is computed over promises already settled — kept or broken — with promised_date in the last 30 days; null means none have settled yet.

GET /v1/stats/penetration

Person-level penetration analytics. Query params: campaign_ids=a,b (comma-separated UUIDs; campaign_id= also accepted), from/to (RFC 3339 bounds on started_at, default all-time). Missing or garbage campaign_ids400.

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_contactanswered_human or a conversation.

Compliance evidence

The durable, append-only artifacts that turn "we follow the rules" into "here is the row that proves it." Every number is a count over write-once tables, never an estimate.

GET /v1/stats/blocked?from&to&campaign_id

The blocked-dial rollup: by-gate counts + a daily series over the durable gate_blocks evidence (one row per debt-gate-campaign-UTC-day the compliance engine REFUSED to dial, written before the lead is skipped).

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).


Billing & plan

The subscription plan and the invoiceable charges.

The per-tier catalog is DATA, not code: it lives in the plan_tiers table and the sellable ladder is starter $25, growth $69, scale$119, enterprise $199 per seat/month, each row carrying its own monthly allowance (included_minutes, included_sms_segments, included_ai_minutes, included_dids). This repo vendors that catalog at data/system-catalog.json, with its provenance in data/system-catalog.SOURCE.json, and npm test fails if any price on these pages drifts from it.

What IS account-wide code (Dialer.Billing.Plan): a 3-seat billing floor, DID overage at $25 per 10 monitored DIDs over the tier quota, and a metered voice-AI add-on at $0.25/connected-min with a $200/month minimum while enabled. Since core#893 that line also applies the published volume tier and the design-partner discount, always in this order: tier allowance → volume tier → design-partner discount → monthly minimum. The minimum is compared LAST, so a discount cannot push a bill under it. The add-on is offered on the tiers whose catalog row declares an included_ai_minutes allowance — growth, scale and enterprise; starter leaves the field empty and is not eligible.

PATCH /v1/tenant

Account self-edit. The body accepts name and nothing else; any other key is ignored and the updated tenant is returned.

There is no self-service plan change on this API. The billing-bearing fields — tier, voice_ai_enabled and seats — decide the invoice, so a tenant bearer that could write them is a direct under-pay vector (downgrade the tier or disable the add-on at period end → cheaper bill). They live on the platform-admin surface (PATCH /v1/admin/tenants/{id}), behind the platform-admin token, which no tenant credential can mint. To change plan, seats or the add-on, ask your platform contact.

GET /v1/billing/summary?from&to

The invoiceable line items for the period, each amount computed server-side from real state (occupied seats, monitored DIDs, metered AI minutes):

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).

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.

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.