Debts & leads
Generated from
DialerDigital/corecommit6e9f026f0a72— specopenapi/dialer-v1.yamlv0.36.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
Bulk lead import, debt detail, per-debt CDR, defense packet.
Endpoints
| Method | Path | Summary |
|---|---|---|
GET | /v1/debts | Account list + search (filters, keyset pagination) |
POST | /v1/debts/import | Bulk JSON lead import (idempotent) |
GET | /v1/debts/{id} | Debt + contacts |
GET | /v1/debts/{id}/call_attempts | Durable per-debt CDR history |
GET | /v1/debts/{id}/defense-packet | Litigation Defense Packet (sealed evidence bundle) |
Account list + search (filters, keyset pagination)
GET /v1/debtsOne page of the tenant's accounts, read from the wide debts_wide view. Tenant-scoped: a debt of another tenant is never returned by any filter or search term — an id, a consumer_ref or a phone that belongs elsewhere yields an EMPTY page, never a 403 (existence is not confirmed).
Phone numbers come back MASKED (primary_phone_masked, *** + last four). A list is a sweep surface; the full E.164 of ONE account stays on GET /v1/debts/{id}. account_number is not part of this projection at all.
phone searches by deterministic blind index and therefore requires a normalized E.164 (+13125550100); anything else is a 400, not an empty page. consumer_ref and external_ref match EXACTLY.
dialable_by selects the accounts dialable at that instant: a debt the disposition engine has not parked (next_dialable_at null) counts as dialable now and is included.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
campaign_id | query | string (uuid) | no | |
state | query | open · in_collection · promise_to_pay · paid · settled · disputed · closed | no | Exact debt state. An unknown value is a 400, not an empty page. |
min_amount_cents | query | integer | no | Lower bound (inclusive) on the balance, in cents. |
max_amount_cents | query | integer | no | Upper bound (inclusive) on the balance, in cents. |
dialable_by | query | string (date-time) | no | RFC-3339 instant; returns accounts dialable at or before it. |
phone | query | string | no | Normalized E.164 of any contact of the debt. Percent-encode the leading + as %2B: it is a reserved query-string character that decodes to a SPACE, so an unescaped number arrives mangled and the request is refused with a 400 rather than silently searching for something else. |
consumer_ref | query | string | no | Exact CMS consumer id. |
external_ref | query | string | no | Exact CMS debt id. |
limit | query | integer | no | Page size (default 50, max 200). |
cursor | query | string | no | Opaque keyset cursor from the previous page's next_cursor. |
Responses
200
One page of accounts.
| Field | Type | Required | Description |
|---|---|---|---|
debts | array of DebtSummary | yes | |
debts[].id | string (uuid) | no | |
debts[].tenant_id | string (uuid) | no | |
debts[].campaign_id | string (uuid) | null | no | |
debts[].campaign_name | string | null | no | |
debts[].external_ref | string | no | |
debts[].consumer_ref | string | no | |
debts[].debt_type | other · student_loan | no | |
debts[].amount_cents | integer | no | |
debts[].currency | string | no | |
debts[].state | open · in_collection · promise_to_pay · paid · settled · disputed · closed | no | |
debts[].last_conversation_at | string (date-time) | null | no | |
debts[].next_dialable_at | string (date-time) | null | no | Null means the debt is dialable now (not parked by the disposition engine). |
debts[].primary_phone_masked | string | null | no | *** + last four of the primary contact; null when the debt has no contact. |
debts[].primary_line_type | mobile · landline · voip · unknown | null | no | |
debts[].primary_timezone | string | null | no | |
debts[].primary_us_state | string | null | no | |
debts[].primary_city | string | null | no | |
debts[].contact_count | integer | no | |
debts[].created_at | string (date-time) | no | |
debts[].updated_at | string (date-time) | no | |
next_cursor | string | null | yes | Opaque keyset cursor; null on the last page. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}Bulk JSON lead import (idempotent)
POST /v1/debts/importPer-item fail-soft, max 10 000 items per request and max 100 contacts per debt. Debts upsert on external_ref (THE Reg F counter key), contacts on phone_e164. Replaying the same payload is safe.
An unrecognized field in an item — or in one of its contacts — fails THAT item and nothing else: it comes back in failed with the item's own index and a message naming the key, while every other item still imports. A bad contact is located by its position inside the item (contact 0: ...). Fail-soft is why this route answers per item instead of rejecting the whole body with a 400 the way the single-object routes do.
A contact sent with is_primary: true asserts which number is THE primary for that debt: the previous primary is demoted in the same transaction and kept (numbers are never deleted), and the new one is inserted or promoted. Omitting is_primary, or sending false, never changes the existing primary. An item carrying more than one is_primary: true fails THAT item and writes nothing — the correction is ambiguous and last-one-wins would lose it silently.
The import is ADDITIVE on a contact's data fields, and they are TRI-STATE. Omitting a field and sending it as "" (or as blanks) mean the same thing — "the feed did not say" — and the stored value is left exactly as it was; a re-import can therefore CORRECT a value but never CLEAR one. Sending the field as null is not a clear either: it FAILS that item, with a message naming the field, and nothing is written for it. There is no verb on this route that empties a stored field, and that is deliberate: "" is what an exporter puts in every blank cell of a bulk feed, so reading it as "erase" would let a routine export silently drop the state and ZIP that decide the NYC per-debt cap. line_type: "unknown" is NOT an empty value — it is a published member of the enum, so the feed IS reclassifying the number and it lands.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
debts | array of DebtImportItem | yes | |
debts[].external_ref | string | yes | THE Reg F counter key (upsert key). |
debts[].consumer_ref | string | yes | |
debts[].debt_type | other · student_loan | no | |
debts[].account_number | string | no | |
debts[].amount_cents | integer | no | |
debts[].currency | string | no | |
debts[].state | open · in_collection · promise_to_pay · paid · settled · disputed · closed | no | |
debts[].campaign_id | string (uuid) | no | |
debts[].prior_attempts_7d | integer | no | How many calls about THIS debt the client already placed from its previous system in the last 7 days. Without it the Reg F 7-in-7 counter starts BLIND on a migrated portfolio — it only ever saw the attempts this platform placed — and a book of business moved mid-cycle can burn the legal cap in its first week. Materialized as attempt rows the gate already reads, so the gate itself is unchanged. 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, like last_conversation_at). Values above 7 are stored as 7 — 7 already saturates every frequency cap the engine has, so the verdict is identical and the cap only bounds the write. A value that is not an integer >= 0 fails THAT item, like any other bad field on this route; omitting it changes nothing. |
debts[].contacts | array of object | no | |
debts[].contacts[].phone_e164 | string | yes | E.164 (upsert key within the debt). |
debts[].contacts[].line_type | mobile · landline · voip · unknown | no | |
debts[].contacts[].timezone | string | no | IANA. |
debts[].contacts[].us_state | string | no | |
debts[].contacts[].city | string | no | |
debts[].contacts[].postal_code | string | no | ZIP of the consumer. It is what resolves the NYC borough for the per-debt cap: without it a NY contact cannot be ruled OUT of the city and gets the strict cap. Refreshed on re-import only when the payload carries it — omitting it, or sending it as "", never clears a stored value, and sending it as null fails the item. Same rule as consumer_ref, timezone, us_state and city: the contract is one, even though this column is the only nullable one of the six. |
debts[].contacts[].is_primary | boolean | no | Asserts THE primary contact of the debt. On import, true demotes the previous primary (kept, never deleted) and promotes this one; omitted or false leaves the existing primary untouched. More than one per item fails the item. |
debts[].contacts[].consumer_ref | string | no |
{
"debts": [
{
"external_ref": "cms-debt-0001",
"consumer_ref": "cms-consumer-9001",
"debt_type": "other",
"account_number": "ACC-1001",
"amount_cents": 125000,
"currency": "USD",
"campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
"contacts": [
{
"phone_e164": "+13125551001",
"line_type": "mobile",
"timezone": "America/Chicago",
"us_state": "IL",
"city": "Chicago",
"is_primary": true
}
]
}
]
}Responses
200
Import outcome (per-item fail-soft).
| Field | Type | Required | Description |
|---|---|---|---|
imported | integer | yes | |
failed | array of object | yes | |
failed[].index | integer | no | |
failed[].error | string | no |
{
"imported": 1,
"failed": []
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}Debt + contacts
GET /v1/debts/{id}Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The debt with its contacts.
| Field | Type | Required | Description |
|---|---|---|---|
debt | object (Debt) | yes | |
debt.id | string (uuid) | no | |
debt.tenant_id | string (uuid) | no | |
debt.campaign_id | string (uuid) | null | no | |
debt.external_ref | string | no | |
debt.consumer_ref | string | no | |
debt.debt_type | other · student_loan | no | |
debt.account_number | string | no | |
debt.amount_cents | integer | no | |
debt.currency | string | no | |
debt.state | open · in_collection · promise_to_pay · paid · settled · disputed · closed | no | |
debt.last_conversation_at | string (date-time) | null | no | |
debt.contacts | array of Contact | no | Present on GET /v1/debts/{id} only. |
debt.contacts[].id | string (uuid) | no | |
debt.contacts[].debt_id | string (uuid) | no | |
debt.contacts[].consumer_ref | string | no | |
debt.contacts[].phone_e164 | string | no | |
debt.contacts[].line_type | mobile · landline · voip · unknown | no | |
debt.contacts[].timezone | string | null | no | |
debt.contacts[].us_state | string | null | no | |
debt.contacts[].city | string | null | no | |
debt.contacts[].postal_code | string | null | no | |
debt.contacts[].is_primary | boolean | no | |
debt.created_at | string (date-time) | no | |
debt.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Durable per-debt CDR history
GET /v1/debts/{id}/call_attemptsFull durable history of the debt (originals + compensating corrections), keyset-paginated. Accepts the same filters as GET /v1/call_attempts; the path debt_id always wins over any ?debt_id= in the query.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
campaign_id | query | string (uuid) | no | |
agent_id | query | string (uuid) | no | |
disposition | query | string | no | Exact match against an OPEN vocabulary: call_attempts.disposition is written VERBATIM (20260725000003_create_disposition_engine.exs) and the seven engine values — answered_human, abandoned, busy, no_answer, canceled, rejected, failed — are a NAMED SUBSET of it, not its boundary: typed agent/AI outcomes (promise_to_pay, wrong_number) and platform markers (not_placed) are equally valid. An unknown code answers 200 with zero rows, never 400. What IS 400 is a malformed SHAPE: the empty string, and the parameter repeated or sent in array form. |
from | query | string (date-time) | no | RFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted). |
to | query | string (date-time) | no | RFC 3339 upper bound. |
limit | query | integer | no | Page size (default 100, max 1000). |
cursor | query | string | no | Opaque keyset cursor from the previous page's next_cursor. |
Responses
200
One page of CDR rows.
| Field | Type | Required | Description |
|---|---|---|---|
call_attempts | array of CallAttempt | yes | |
call_attempts[].id | string (uuid) | no | |
call_attempts[].tenant_id | string (uuid) | no | |
call_attempts[].debt_id | string (uuid) | no | |
call_attempts[].campaign_id | string (uuid) | null | no | |
call_attempts[].agent_id | string (uuid) | null | no | |
call_attempts[].consumer_ref | string | no | |
call_attempts[].debt_key | string | no | |
call_attempts[].call_uuid | string | null | no | |
call_attempts[].from_number | string | null | no | |
call_attempts[].to_number | string | no | |
call_attempts[].started_at | string (date-time) | null | no | |
call_attempts[].answered_at | string (date-time) | null | no | |
call_attempts[].ended_at | string (date-time) | null | no | |
call_attempts[].disposition | string | null | no | Engine dispositions: answered_human|abandoned|busy|no_answer|canceled|rejected|failed; typed agent/AI outcomes are free-form (e.g. promise_to_pay). |
call_attempts[].hangup_cause | string | null | no | |
call_attempts[].sip_response_code | integer | null | no | |
call_attempts[].compliance_snapshot | object | null | no | Frozen decision evidence at originate time. |
call_attempts[].amd_verdict | string | null | no | |
call_attempts[].recording_uri | string | null | no | Where the call recording landed (object path/URI); null when the call was not recorded. |
call_attempts[].ai_used | boolean | no | Whether the voice-AI bot ran on this call (the AI add-on meter key). |
call_attempts[].note | string | null | no | The agent's free-text note about the call, entered at disposition time on ANY disposition. It rides on the correction record that carries the disposition, so the ORIGINAL row is normally null. Encrypted at rest (per-tenant envelope) and never queried by content. null = no note, or a record written before the column existed. |
call_attempts[].corrects_id | string (uuid) | null | no | |
call_attempts[].inserted_at | string (date-time) | no | |
next_cursor | string | null | yes | Opaque keyset cursor; null on the last page. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Litigation Defense Packet (sealed evidence bundle)
GET /v1/debts/{id}/defense-packetONE sealed JSON evidence bundle per debt, assembled in a single tenant transaction: full immutable CDR history with frozen compliance snapshots, gate refusals, consent/DNC/C&D state, promises, callbacks, supervision audit and the policy versions in force. integrity.digest is a SHA-256 over the canonical JSON WITHOUT the integrity key (object keys sorted bytewise, no insignificant whitespace — any third party can re-verify; recipe in API.md). With ?format=zip (or Accept: application/zip) ships a ZIP of packet.json + summary.txt with Content-Disposition: attachment and X-Artifact-SHA256 over the ZIP bytes.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
format | query | zip | no | zip for the downloadable artifact (equivalent: Accept: application/zip). |
Responses
200
The sealed evidence bundle (JSON envelope or ZIP artifact).
| Field | Type | Required | Description |
|---|---|---|---|
packet | "litigation_defense" | no | |
packet_version | integer | no | |
tenant_id | string (uuid) | no | |
packet_generated_at | string (date-time) | no | |
debt | object | no | |
contacts | array of object | no | |
call_attempts | array of object | no | FULL history, originals + corrections, each with the frozen compliance_snapshot. |
conversations | array of object | no | Reg F G5 contact anchors. |
gate_blocks | array of object | no | Every durable refusal (frozen reason_detail). |
consents | array of object | no | |
dnc | object | no | |
dnc.tenant_listings | array of object | no | |
dnc.global_listings | array of object | no | |
dnc.reassigned_numbers | array of object | no | |
cease_and_desist | array of object | no | Consumer-wide ("" scope) + debt-scoped. |
promises | array of object | no | |
callbacks | array of object | no | |
supervision_actions | array of object | no | |
policy_versions | array of string | no | |
integrity | object | no | |
integrity.algorithm | "sha256" | no | |
integrity.digest | string | no | Hex sha256 over the canonical envelope without integrity. |
integrity.canonicalization | string | no | |
integrity.worm_attestation | object | null | no |
{
"packet": "litigation_defense",
"packet_version": 1,
"tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
"packet_generated_at": "2026-07-05T10:00:00Z",
"debt": {
"id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"external_ref": "cms-debt-0001"
},
"contacts": [],
"call_attempts": [],
"conversations": [],
"gate_blocks": [],
"consents": [],
"dnc": {
"tenant_listings": [],
"global_listings": [],
"reassigned_numbers": []
},
"cease_and_desist": [],
"promises": [],
"callbacks": [],
"supervision_actions": [],
"policy_versions": [
"usa-cell.v1"
],
"integrity": {
"algorithm": "sha256",
"digest": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"canonicalization": "sorted-keys utf-8, no insignificant whitespace",
"worm_attestation": null
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}