Skip to content

Debts & leads ​

Generated from DialerDigital/core commit 0fc18008b3c8 — spec openapi/dialer-v1.yaml v0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.

Español

Bulk lead import, debt detail, per-debt CDR, defense packet.

Endpoints ​

MethodPathSummary
GET/v1/debtsAccount list + search (filters, keyset pagination)
POST/v1/debts/searchSearch accounts with the filter in the request body (phone included)
POST/v1/debts/importBulk JSON or CSV lead import (idempotent)
GET/v1/debts/{id}Debt + contacts
GET/v1/debts/{id}/call_attemptsDurable per-debt CDR history
GET/v1/debts/{id}/messagesWhat was sent to a debt, through which channel
GET/v1/debts/{id}/defense-packetLitigation Defense Packet (sealed evidence bundle)
GET/v1/debts/{id}/defense-packet/archivesList the archived Litigation Defense Packets of a debt
POST/v1/debts/{id}/defense-packet/archivesArchive the Litigation Defense Packet (WORM store + RFC 3161 timestamp)
GET/v1/debts/{id}/defense-packet/archives/{aid}/attestationAttestation kit of an archived Litigation Defense Packet

Account list + search (filters, keyset pagination) ​

http
GET /v1/debts

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

There is no phone parameter: a number in the query string would end up in ingress access logs, so ?phone= is refused with a 400 whose code is phone_not_in_query. Search by phone with POST /v1/debts/search, which takes it in the body. 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.

campaign_id selects the campaign's POPULATION — the accounts whose own campaign_id is that campaign, plus the members of any list associated with it (/v1/debt-lists) — each account once. The campaign_id field of each row is still the account's own campaign (its direct slot).

campaign_id, state, debt_type, us_state, min_amount_cents and max_amount_cents are the SEGMENT criteria (ADR #221): a closed vocabulary validated in one place (Dialer.Debts.Segment) and combined with AND. A value outside the vocabulary is a 400, never an empty page. Segments select accounts by attributes only: phone, consumer_ref, external_ref and debtor_name are never segment criteria (this listing keeps its own exact searches by the first three; the debtor's name is not searchable). A query parameter this endpoint does not know is ignored, as before.

include=dialability (ADR #269) adds to every row dialability: why the account is not dialable and each phone's consent, evaluated now by the SAME gates the dialer runs (DebtDialability). It is opt-in and costs a bounded number of reads per PAGE, not per row, so the page is capped at 50: a larger limit is a 400, never a silent cut. Without include the body is byte-for-byte the one it always was (the field is absent, not null). dialable: true never means "a call will be placed": see DebtDialability.not_evaluated_gates. An unknown include value is a 400.

Parameters

NameInTypeRequiredDescription
campaign_idquerystring (uuid)no
statequeryopen · in_collection · promise_to_pay · paid · settled · disputed · closednoExact debt state. An unknown value is a 400, not an empty page.
state[]queryarray of open · in_collection · promise_to_pay · paid · settled · disputed · closednoSeveral debt states at once (state[]=open&state[]=disputed): an account matches when its state is ANY of them. Repeats are ignored; an empty or unknown entry is a 400.
debt_typequeryother · student_loannoExact debt type. An unknown value is a 400, not an empty page. Part of the closed segment vocabulary (Dialer.Debts.Segment, ADR #221).
debt_type[]queryarray of other · student_loannoSeveral debt types at once (debt_type[]=other&debt_type[]=student_loan); matches ANY.
us_statequerystringnoUS state of the account's PRIMARY contact: a USPS two-letter code in upper case (state, possession or military code; Dialer.Compliance.UsStates). Anything else (tx, XX) is a 400, not an empty page. An account with no contact never matches.
us_state[]queryarray of stringnoSeveral US states at once (us_state[]=TX&us_state[]=NY); matches ANY.
min_amount_centsqueryintegernoLower bound (inclusive) on the balance, in cents.
max_amount_centsqueryintegernoUpper bound (inclusive) on the balance, in cents.
dialable_byquerystring (date-time)noRFC-3339 instant; returns accounts dialable at or before it.
consumer_refquerystringnoExact CMS consumer id.
external_refquerystringnoExact CMS debt id.
limitqueryintegernoPage size (default 50, max 200). With include=dialability the maximum is 50 and a larger value is a 400.
includequeryarray of dialabilitynoExtra projections to add to every row, comma-separated (include=dialability); include[]=dialability is accepted too. dialability (ADR #269): why the account is not dialable and each phone's consent, evaluated now with the dialer's own gates, opt-in and capped at a page of 50. An unknown value is a 400, never ignored.
cursorquerystringnoOpaque keyset cursor from the previous page's next_cursor.

Responses

200 ​

One page of accounts.

FieldTypeRequiredDescription
debtsarray of DebtSummaryyes
debts[].idstring (uuid)no
debts[].tenant_idstring (uuid)no
debts[].campaign_idstring (uuid) | nullnoThe debt's DIRECT slot, as on Debt. A debt also in a campaign by a list is listed under ?campaign_id= of that campaign with this field unchanged.
debts[].campaign_namestring | nullno
debts[].external_refstringno
debts[].consumer_refstringno
debts[].debt_typeother · student_loanno
debts[].amount_centsintegerno
debts[].currencystringno
debts[].stateopen · in_collection · promise_to_pay · paid · settled · disputed · closedno
debts[].last_conversation_atstring (date-time) | nullno
debts[].next_dialable_atstring (date-time) | nullnoNull means the debt is dialable now (not parked by the disposition engine).
debts[].primary_phone_maskedstring | nullno*** + last four of the primary contact; null when the debt has no contact.
debts[].primary_line_typemobile · landline · voip · unknown | nullno
debts[].primary_timezonestring | nullno
debts[].primary_us_statestring | nullno
debts[].primary_citystring | nullno
debts[].contact_countintegerno
debts[].created_atstring (date-time)no
debts[].updated_atstring (date-time)no
debts[].dialabilityobject (DebtDialability)noPresent only with include=dialability (ADR #269). The verdict is "now" and ages in seconds: it informs, it never grants permission; the engine decides at dial time. It is evaluated with the dialer's OWN gates over one snapshot read per page.
debts[].dialability.evaluated_atstring (date-time)yes
debts[].dialability.dialablebooleanyesThe phone the dialer would call passes every evaluated gate AND no account-level reason applies. NOT a promise that a call will be placed: see not_evaluated_gates.
debts[].dialability.blocked_reasonsarray of DebtBlockedReasonyesAccount reasons plus the reasons of the phone the dialer would call; empty iff dialable. ["no_phones"] when the account has no contact.
debts[].dialability.eligible_atstring (date-time) | nullyesevaluated_at when nothing blocks. Otherwise the first instant, within 9 days, at which the time-dependent gates (quiet hours, Reg F, the state matrix) stop blocking the phone the dialer would call, ONLY when nothing else blocks it; null when another reason applies or no instant exists.
debts[].dialability.phonesarray of DebtDialabilityPhoneyesUp to 5 contacts, in the dialer's rank order. Empty when the account could not be evaluated.
debts[].dialability.phones[].phone_maskedstring | nullyes*** + the last four digits, never more.
debts[].dialability.phones[].line_typemobile · landline · voip · unknown | nullyes
debts[].dialability.phones[].feed_candidatebooleanyesTrue for the phone the dialer would call (the account's rank-1 contact: primary first, then the oldest). The others are informative.
debts[].dialability.phones[].consentobject (DebtDialabilityConsent)yesThe consent on file for the number. Only the state, the channel and the instant: never the free-text source.
debts[].dialability.phones[].dialablebooleanyesEvery evaluated gate lets THIS phone through; blocked_reasons is empty iff true.
debts[].dialability.phones[].blocked_reasonsarray of DebtBlockedReasonyesALL the reasons, not only the first (gates are not short-circuited), in the order of the dialer's gate chain.
debts[].dialability.phones_truncatedbooleanyesThe account has more contacts than phones lists.
debts[].dialability.not_evaluated_gatesarray of stringyesWhat dialable does NOT cover because it depends on the campaign, the call or the tenant (AI disclosure, artificial voice, caller-ID attestation, campaign state, plan, spend caps, pacing).
next_cursorstring | nullyesOpaque keyset cursor; null on the last page.

400 — BadRequest ​

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Search accounts with the filter in the request body (phone included) ​

http
POST /v1/debts/search

Same filters, pagination, tenant scoping (RLS) and masked projection as GET /v1/debts, with the filter in a JSON body. It exists so a debtor's phone number never travels in the URL (ingress and proxy access logs). A search is a read: no state changes.

phone searches by deterministic blind index and requires a normalized E.164 (+13125550100); anything else is a 400, not an empty page. A body key outside the schema is a 400. An empty body {} returns the first page of the whole portfolio.

"include": ["dialability"] adds the per-account dialability verdict, with the same meaning, cap (page of 50) and cost as on GET /v1/debts.

Request body (JSON, required)

FieldTypeRequiredDescription
campaign_idstring (uuid)no
stateopen · in_collection · promise_to_pay · paid · settled · disputed · closed | array of open · in_collection · promise_to_pay · paid · settled · disputed · closednoOne debt state or several (matches ANY).
debt_typeother · student_loan | array of other · student_loannoOne debt type or several (matches ANY).
us_statestring | array of stringnoOne USPS two-letter code of the PRIMARY contact or several (matches ANY).
min_amount_centsintegerno
max_amount_centsintegerno
dialable_bystring (date-time)no
phonestringnoNormalized E.164 of any contact of the debt.
consumer_refstringno
external_refstringno
limitintegernoPage size. With include the maximum is 50 and a larger value is a 400.
cursorstringno
includearray of dialabilitynoExtra projections for every row (["dialability"], ADR #269): same meaning, cap and cost as include on GET /v1/debts.

Responses

200 ​

One page of accounts.

FieldTypeRequiredDescription
debtsarray of DebtSummaryyes
debts[].idstring (uuid)no
debts[].tenant_idstring (uuid)no
debts[].campaign_idstring (uuid) | nullnoThe debt's DIRECT slot, as on Debt. A debt also in a campaign by a list is listed under ?campaign_id= of that campaign with this field unchanged.
debts[].campaign_namestring | nullno
debts[].external_refstringno
debts[].consumer_refstringno
debts[].debt_typeother · student_loanno
debts[].amount_centsintegerno
debts[].currencystringno
debts[].stateopen · in_collection · promise_to_pay · paid · settled · disputed · closedno
debts[].last_conversation_atstring (date-time) | nullno
debts[].next_dialable_atstring (date-time) | nullnoNull means the debt is dialable now (not parked by the disposition engine).
debts[].primary_phone_maskedstring | nullno*** + last four of the primary contact; null when the debt has no contact.
debts[].primary_line_typemobile · landline · voip · unknown | nullno
debts[].primary_timezonestring | nullno
debts[].primary_us_statestring | nullno
debts[].primary_citystring | nullno
debts[].contact_countintegerno
debts[].created_atstring (date-time)no
debts[].updated_atstring (date-time)no
debts[].dialabilityobject (DebtDialability)noPresent only with include=dialability (ADR #269). The verdict is "now" and ages in seconds: it informs, it never grants permission; the engine decides at dial time. It is evaluated with the dialer's OWN gates over one snapshot read per page.
debts[].dialability.evaluated_atstring (date-time)yes
debts[].dialability.dialablebooleanyesThe phone the dialer would call passes every evaluated gate AND no account-level reason applies. NOT a promise that a call will be placed: see not_evaluated_gates.
debts[].dialability.blocked_reasonsarray of DebtBlockedReasonyesAccount reasons plus the reasons of the phone the dialer would call; empty iff dialable. ["no_phones"] when the account has no contact.
debts[].dialability.eligible_atstring (date-time) | nullyesevaluated_at when nothing blocks. Otherwise the first instant, within 9 days, at which the time-dependent gates (quiet hours, Reg F, the state matrix) stop blocking the phone the dialer would call, ONLY when nothing else blocks it; null when another reason applies or no instant exists.
debts[].dialability.phonesarray of DebtDialabilityPhoneyesUp to 5 contacts, in the dialer's rank order. Empty when the account could not be evaluated.
debts[].dialability.phones[].phone_maskedstring | nullyes*** + the last four digits, never more.
debts[].dialability.phones[].line_typemobile · landline · voip · unknown | nullyes
debts[].dialability.phones[].feed_candidatebooleanyesTrue for the phone the dialer would call (the account's rank-1 contact: primary first, then the oldest). The others are informative.
debts[].dialability.phones[].consentobject (DebtDialabilityConsent)yesThe consent on file for the number. Only the state, the channel and the instant: never the free-text source.
debts[].dialability.phones[].dialablebooleanyesEvery evaluated gate lets THIS phone through; blocked_reasons is empty iff true.
debts[].dialability.phones[].blocked_reasonsarray of DebtBlockedReasonyesALL the reasons, not only the first (gates are not short-circuited), in the order of the dialer's gate chain.
debts[].dialability.phones_truncatedbooleanyesThe account has more contacts than phones lists.
debts[].dialability.not_evaluated_gatesarray of stringyesWhat dialable does NOT cover because it depends on the campaign, the call or the tenant (AI disclosure, artificial voice, caller-ID attestation, campaign state, plan, spend caps, pacing).
next_cursorstring | nullyesOpaque keyset cursor; null on the last page.

400 — BadRequest ​

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Bulk JSON or CSV lead import (idempotent) ​

http
POST /v1/debts/import

Accepts the existing JSON envelope or a multipart/form-data upload. The multipart form carries file (text/csv), mapping (a JSON object whose keys are import targets and whose values are CSV header names), and optional separator (comma, semicolon, or tab; default comma). Debt targets: JSON field names minus state. A mapped last_conversation_at column goes through the same validation as the JSON field (RFC 3339 with a UTC offset, never in the future) 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. A single contact may be mapped with contact.phone_e164, contact.line_type, contact.timezone, contact.us_state, contact.city, contact.postal_code, contact.is_primary, or contact.consumer_ref. The debtor's name maps to debtor_name (optional, at most 200 characters, encrypted at rest; see DebtImportItem). A mapped delinquent_since column (a YYYY-MM-DD date) goes through the same validation as the JSON field; a bad cell fails only its row.

With ?mapping_id= the multipart form carries only file: the stored template supplies the mapping and the separator, and sending mapping or separator as well is refused with 422. A template that maps one field from two columns, a field the CSV importer does not know, or options.default_currency (the upload has no currency default), answers 422 and imports nothing.

CSV is UTF-8; a leading BOM is accepted. The default file limit is 4 000 000 bytes and the default row limit is 10 000 data rows. They are governed per-tenant by the R19 catalog keys debt_csv_max_bytes and debt_import_max_rows; the latter applies to JSON too. Parsing is fail-closed: malformed CSV, invalid encoding, missing/unknown multipart fields, missing mapped headers, or an unsupported separator imports nothing and answers 422; an oversized field answers 413. The uploaded file stays in memory and is never written to disk or logged.

After parsing and mapping, both representations enter the SAME item scrub, normalisation and import pipeline. Per-item fail-soft, the row limit above 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. Failed rows return only their zero-based index and reason; no CSV cell value is echoed.

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.

Parameters

NameInTypeRequiredDescription
mapping_idquerystring (uuid)noThe import-mapping template the file was mapped with. It is resolved BEFORE anything is imported (404 when it is not the tenant's, 409 when it is archived: nothing is written) and the version used is recorded after the import (GET /v1/import-mappings/{id}/uses). On the multipart form of POST /v1/debts/import the template replaces the mapping and separator parts. Absent, the import is unchanged.
mapping_versionqueryintegernoThe version of mapping_id to record. Default the current one. Needs mapping_id.
import_idquerystring (uuid)noNames ONE import that the client sends in several requests (chunks). The uses those requests leave read back in GET /v1/import-mappings/{id}/uses as a single use — counts added up, id = this import_id — as long as they share the version and the list. Needs mapping_id (400 otherwise, and for a value that is not a UUID). Absent, each request is its own use.

Request body (JSON, required)

FieldTypeRequiredDescription
debtsarray of DebtImportItemyes
debts[].external_refstringyesTHE Reg F counter key (upsert key).
debts[].consumer_refstringyes
debts[].debt_typeother · student_loanno
debts[].account_numberstringno
debts[].debtor_namestring | nullnoOptional full name of the debtor, free text. Whitespace is collapsed and trimmed; a blank value is stored as no name. More than 200 characters fails the ITEM (row error debtor_name should be at most 200 character(s)), not the batch. Encrypted at rest, not searchable, and never sent to analytics, webhooks or logs. Like the other debt fields it is a snapshot: a re-import that omits it clears the stored name.
debts[].amount_centsintegerno
debts[].currencystringno
debts[].stateopen · in_collection · promise_to_pay · paid · settled · disputed · closedno
debts[].campaign_idstring (uuid)no
debts[].prior_attempts_7dintegernoHow 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[].last_conversation_atstring (date-time)noWhen the client last spoke with the debtor, from its previous system (RFC 3339 with a UTC offset). Without it the Reg F 7-day post-contact cooldown (12 CFR 1006.14(b)(2)(i)(B)) starts BLIND on a migrated portfolio: the platform only knows the conversations it recorded, and could call again inside the window. Materialized as a conversations row with source: cms_import, which the gate already reads, so the gate itself is unchanged; the debt's last_conversation_at anchor advances to it. Re-sending the same payload adds nothing, and an OLDER date never moves the anchor back. Reports that measure platform activity (penetration, abandonment rate, the attempts export) do not count it. A malformed value, one without a UTC offset, or one in the future fails THAT item (a future date would hold the cooldown open past its window); omitting it changes nothing.
debts[].delinquent_sincestring (date)noThe calendar date the debt went delinquent (YYYY-MM-DD), from the client's previous system. It is the debt's age: segmentation filters such as "delinquent for 31 to 90 days" are computed against it. A snapshot, like debtor_name: re-importing the debt without it clears it. A debt with no date never matches an age filter. Anything that is not a real calendar date in that exact form (null, an empty string, a number, a timestamp, 2026-02-30) or a date in the future fails THAT item; omitting it is fine. In a CSV import a blank cell is the same as omitting the field.
debts[].contactsarray of objectno
debts[].contacts[].phone_e164stringyesE.164 (upsert key within the debt).
debts[].contacts[].line_typemobile · landline · voip · unknownno
debts[].contacts[].timezonestringnoIANA.
debts[].contacts[].us_statestringno
debts[].contacts[].citystringno
debts[].contacts[].postal_codestringnoZIP 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_primarybooleannoAsserts 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_refstringno
json
{
  "debts": [
    {
      "external_ref": "cms-debt-0001",
      "consumer_ref": "cms-consumer-9001",
      "debt_type": "other",
      "account_number": "ACC-1001",
      "debtor_name": "Ana María Pérez",
      "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).

FieldTypeRequiredDescription
importedintegeryes
failedarray of objectyes
failed[].indexintegerno
failed[].errorstringno
mappingobjectnoPresent only when the request named mapping_id: the template version recorded as used.
mapping.idstring (uuid)yes
mapping.versionintegeryes
json
{
  "imported": 1,
  "failed": []
}

400 — BadRequest ​

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

409 — Conflict ​

Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.

json
{
  "error": {
    "code": "conflict",
    "message": "invalid state transition"
  }
}

413 ​

The CSV file or another multipart field exceeds its byte ceiling.

422 — Unprocessable ​

Shape is fine, semantics are not (broken domain rule).

json
{
  "error": {
    "code": "unprocessable",
    "message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
  }
}

429 — ImportThrottled ​

The import was refused BEFORE any row was read (core#2505). Two codes, both with Retry-After. import_in_progress: the tenant already has its maximum of imports running (2 by default, per node); retry when one finishes. rate_limited: more than 10 imports in the fixed window (by default) for the tenant, which is a bound per tenant and not per key because the connection pool an import drains is the cell's. Applies to POST /v1/debts/import and POST /v1/debt-lists/{id}/import alike. The per-key request quota can still answer rate_limited first.

in_flight

json
{
  "error": {
    "code": "import_in_progress",
    "message": "an import of this tenant is still running; retry when it finishes"
  }
}

window

json
{
  "error": {
    "code": "rate_limited",
    "message": "import rate limit exceeded; retry later"
  }
}

Debt + contacts ​

http
GET /v1/debts/{id}

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The debt with its contacts.

FieldTypeRequiredDescription
debtobject (Debt)yes
debt.idstring (uuid)no
debt.tenant_idstring (uuid)no
debt.campaign_idstring (uuid) | nullnoThe debt's DIRECT slot: the campaign it was imported or assigned to. It is not the whole answer to "which campaigns work this debt": a debt is also in the population of every campaign a list holding it is associated with (/v1/debt-lists).
debt.external_refstringno
debt.consumer_refstringno
debt.debt_typeother · student_loanno
debt.account_numberstringno
debt.debtor_namestring | nullnoThe debtor's name as the CMS sent it on import (core#1529); null when it sent none. Encrypted at rest. Served by GET /v1/debts/{id}, the debtor card and the agent's call card; NOT by the GET /v1/debts listing, the webhooks or the analytics export.
debt.amount_centsintegerno
debt.currencystringno
debt.stateopen · in_collection · promise_to_pay · paid · settled · disputed · closedno
debt.last_conversation_atstring (date-time) | nullno
debt.contactsarray of ContactnoPresent on GET /v1/debts/{id} only.
debt.contacts[].idstring (uuid)no
debt.contacts[].debt_idstring (uuid)no
debt.contacts[].consumer_refstringno
debt.contacts[].phone_e164stringno
debt.contacts[].line_typemobile · landline · voip · unknownno
debt.contacts[].timezonestring | nullno
debt.contacts[].us_statestring | nullno
debt.contacts[].citystring | nullno
debt.contacts[].postal_codestring | nullno
debt.contacts[].is_primarybooleanno
debt.created_atstring (date-time)no
debt.updated_atstring (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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "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).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Durable per-debt CDR history ​

http
GET /v1/debts/{id}/call_attempts

Full 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
campaign_idquerystring (uuid)no
agent_idquerystring (uuid)no
dispositionquerystringnoExact match against an OPEN vocabulary: call_attempts.disposition is written VERBATIM (20260725000003_create_disposition_engine.exs) and the engine values — answered_human, abandoned, delivered, ai_conversation, answered_machine, ai_stream_lost, 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.
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
limitqueryintegernoPage size (default 100, max 1000).
cursorquerystringnoOpaque keyset cursor from the previous page's next_cursor.

Responses

200 ​

One page of CDR rows.

FieldTypeRequiredDescription
call_attemptsarray of CallAttemptyes
call_attempts[].idstring (uuid)no
call_attempts[].tenant_idstring (uuid)no
call_attempts[].debt_idstring (uuid)no
call_attempts[].campaign_idstring (uuid) | nullno
call_attempts[].agent_idstring (uuid) | nullno
call_attempts[].consumer_refstringno
call_attempts[].debt_keystringno
call_attempts[].call_uuidstring | nullno
call_attempts[].from_numberstring | nullno
call_attempts[].to_numberstringno
call_attempts[].started_atstring (date-time) | nullno
call_attempts[].answered_atstring (date-time) | nullno
call_attempts[].ended_atstring (date-time) | nullno
call_attempts[].dispositionstring | nullnoEngine dispositions: answered_human|abandoned|delivered|ai_conversation|answered_machine|ai_stream_lost|busy|no_answer|canceled|rejected|failed; typed agent/AI outcomes are free-form (e.g. promise_to_pay). An answered call that was never bridged is named by what the answer was (core#1323): answered_machine on an AMD machine verdict, ai_conversation for a voice-AI campaign, delivered for a TTS/IVR/survey campaign, and abandoned ONLY when an agent was expected (the TSR/FCC abandoned call). Rows written before that release keep abandoned for all four. A voice-AI call whose session voice-AI refused because it was full, and that no agent took, is also abandoned (core#1326): the person answered and nobody spoke to them.
call_attempts[].hangup_causestring | nullno
call_attempts[].sip_response_codeinteger | nullno
call_attempts[].compliance_snapshotobject | nullnoFrozen decision evidence at originate time.
call_attempts[].amd_verdictstring | nullno
call_attempts[].recording_uristring | nullnoWhere the call recording landed (object path/URI); null when the call was not recorded.
call_attempts[].ai_usedbooleannoWhether the voice-AI bot ran on this call (the AI add-on meter key).
call_attempts[].notestring | nullnoThe 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_idstring (uuid) | nullno
call_attempts[].inserted_atstring (date-time)no
call_attempts[].disclosureobject (CallAttemptDisclosure)noWhat the agent DECLARED about the mandatory collection notice on this call (core#1587, ADR #211). On GET /v1/call_attempts and the defense packet v2 rows; every row of an attempt (the original and its corrections) carries the attempt's state. It is a declaration, never proof that the notice was said or heard: there is no "verified" nor "compliant" state. attested — the current attestation says delivered. declared_not_delivered — it says not_delivered (the reason is on the event). unattested — the call is IN SCOPE (it has a debt and the pipeline finalised it answered_human or transferred) and nobody attested. not_applicable — out of scope (no debt, or not a human contact). undecidable — the call has no final row yet, so scope cannot be decided. Scope never depends on the code the agent typed.
call_attempts[].disclosure.statusattested · declared_not_delivered · unattested · not_applicable · undecidableyes
call_attempts[].disclosure.event_idstring (uuid) | nullyesThe current attestation (null when there is none).
call_attempts[].disclosure.atstring (date-time) | nullyesServer time of the current attestation.
call_attempts[].disclosure.revisedbooleanyestrue when the attempt has more than one attestation (one rectifies another).
call_attempts[].disclosure.scriptCallAttemptDisclosureScript | nullnoOnly on GET /v1/call_attempts (core#1690, ADR #219 Decisions 3-5); the defense packet v2 rows do not carry it. The approved script that applies to this call, resolved server-side from the facts frozen in the attempt (consumer state and debt type), so the station computes nothing. Only versions approved before the call ended (or approved now, while it has not ended) and not retired today take part, so the version shown is one the attestation accepts. null when the tenant has no such version, when the attempt has no frozen facts, or when no version applies. It is not the version the agent attested: that one is sealed on the attestation event.
call_attempts[].disclosure.script_matchmatched · other_version · unscripted · not_applicablenoOnly on GET /v1/call_attempts (core#1691, ADR #219 Decision 7). Compares the script version the current attestation NAMES with the one script publishes now. matched — the same. other_version — it names another one (for example one replaced after the call started, which stays valid) or none is published any more. unscripted — there is an attestation that names no version and a script did apply. not_applicable — there is no attestation, or no script to compare. A reading mark: it blocks nothing and does not say that any text complies with any rule.
next_cursorstring | nullyesOpaque keyset cursor; null on the last page.

400 — BadRequest ​

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "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).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

What was sent to a debt, through which channel ​

http
GET /v1/debts/{id}/messages

Unified read per debt over the SMS and email ledgers (core#1772). It is a read-model DERIVED at read time (ADR #218): no materialized view. Each entry is an outbound ORIGINAL; status is the current one (latest transition). Windowed (from/to, at most 31 days, default the trailing 31) and keyset-paginated (default 50, max 200); channel narrows to one ledger.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
channelquerysms · emailno
limitqueryintegernoPage size (default 100, max 1000).
cursorquerystringnoOpaque keyset cursor from the previous page's next_cursor.

Responses

200 ​

One page of sent messages, newest first.

FieldTypeRequiredDescription
messagesarray of DebtMessageno
messages[].channelsms · emailno
messages[].idstring (uuid)no
messages[].statusstringnoCurrent status (latest transition of the original).
messages[].recipientstring | nullno
messages[].subjectstring | nullno
messages[].body_previewstring | nullno
messages[].provider_idstring (uuid) | nullno
messages[].campaign_idstring (uuid) | nullno
messages[].debt_idstring (uuid)no
messages[].occurred_atstring (date-time)no
messages[].last_event_atstring (date-time)no
next_cursorstring | nullno

400 — BadRequest ​

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "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).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Litigation Defense Packet (sealed evidence bundle) ​

http
GET /v1/debts/{id}/defense-packet

ONE 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
formatqueryzipnozip for the downloadable artifact (equivalent: Accept: application/zip).

Responses

200 ​

The sealed evidence bundle (JSON envelope or ZIP artifact).

FieldTypeRequiredDescription
packet"litigation_defense"no
packet_versionintegerno
tenant_idstring (uuid)no
packet_generated_atstring (date-time)no
debtobjectno
contactsarray of objectno
call_attemptsarray of objectnoFULL history, originals + corrections, each with the frozen compliance_snapshot.
conversationsarray of objectnoReg F G5 contact anchors. Each carries source: platform (recorded by this system) or cms_import (declared by the client on import).
gate_blocksarray of objectnoEvery durable refusal (frozen reason_detail).
consentsarray of objectno
dncobjectno
dnc.tenant_listingsarray of objectno
dnc.global_listingsarray of objectno
dnc.reassigned_numbersarray of objectno
cease_and_desistarray of objectnoConsumer-wide ("" scope) + debt-scoped.
promisesarray of objectno
callbacksarray of objectno
suppression_eventsarray of objectnoVersion 5 (core#2554). The history of the opt-outs of the debt's numbers and of its consumer_ref, oldest first: kind (dnc_added, dnc_removed, consent_granted, consent_revoked, cease_filed), actor (user:<id>, key:<id>, system:sms_inbound, system:ivr…), source, channel, effective_at and occurred_at. The number is published as its last four digits only. Each event is sealed in the tenant chain (subject_table: suppression_events, in audit_chain.links). Events exist from the release that introduced the history: a suppression older than that has none.
supervision_actionsarray of objectno
conference_legsarray of objectnoVersion 3 (core#1701, ADR #223). The THIRD PARTIES that joined a call of the debt as a conference leg: each leg's own call_attempts rows, originals and corrections (corrects_id). They carry no debt, so call_attempts never lists them; compliance_snapshot.conference_of names the call they joined and the closing correction states when and why the leg ended (conference_call_ended when the call ended first).
conference_actionsarray of objectnoVersion 3. The conference_add / conference_remove commands on the debt's calls: who was added (destination), the leg (conf=<uuid> in reason), the consumer's attestation (consumer_agreed=true) and the result.
policy_versionsarray of stringno
disclosure_eventsarray of DisclosureEventnoVersion 2 (core#1587). EVERY attestation of the collection notice on the debt's calls, also the ones a later attestation rectified (supersedes_id). They state what the agent DECLARED. The chain links that seal them travel in audit_chain.links (subject_table: call_disclosure_events).
disclosure_events[].idstring (uuid)no
disclosure_events[].tenant_idstring (uuid)no
disclosure_events[].call_attempt_idstring (uuid)no
disclosure_events[].debt_idstring (uuid)no
disclosure_events[].agent_idstring (uuid)no
disclosure_events[].evidence_kindagent_attestationno
disclosure_events[].outcomedelivered · not_deliveredno
disclosure_events[].reasonstring | nullno
disclosure_events[].notestring | nullno
disclosure_events[].script_sha256string | nullno
disclosure_events[].script_version_idstring (uuid)noPresent only when the attestation is linked to a script library version (core#1690).
disclosure_events[].script_resolutionexact · state · debt_type · tenant_defaultnoPresent only with script_version_id; the precedence branch the version's selector belongs to for this call.
disclosure_events[].supersedes_idstring (uuid) | nullno
disclosure_events[].occurred_atstring (date-time)no
disclosure_events[].created_atstring (date-time)no
disclosure_summaryobjectnoVersion 2. Counts per attempt of the debt: contacted (in scope or already attested) = attested + unattested, where attested includes the declared_not_delivered ones (listed apart). undecidable calls have no final row yet and are not in contacted.
disclosure_summary.contactedintegerno
disclosure_summary.attestedintegerno
disclosure_summary.declared_not_deliveredintegerno
disclosure_summary.unattestedintegerno
disclosure_summary.undecidableintegerno
disclosure_scriptsarray of DisclosureScriptnoVersion 4 (core#1691, ADR #219 Decision 7). Every script version that some attestation of the debt NAMES (script_version_id on a disclosure_events row), with the full text, text_sha256, legal_ref, authored_by, status and the approval / retirement events (actor, reviewed_by, occurred_at). A version retired after the call is still listed; an attestation that names no version adds nothing. Empty when no attestation names one.
disclosure_scripts[].idstring (uuid)yes
disclosure_scripts[].tenant_idstring (uuid)yes
disclosure_scripts[].kindcollection_noticeyes
disclosure_scripts[].jurisdictionstringyes
disclosure_scripts[].debt_typestringyes
disclosure_scripts[].versionintegeryesPer tenant and selector.
disclosure_scripts[].textstringyes
disclosure_scripts[].text_sha256stringyesSHA-256 (lower hex) of text, derived by core.
disclosure_scripts[].legal_refstringyes
disclosure_scripts[].authored_bystringyes
disclosure_scripts[].statusdraft · approved · retiredyesRead from the sealed events; no approved event means draft.
disclosure_scripts[].approvalDisclosureScriptEvent | nullno
disclosure_scripts[].retirementDisclosureScriptEvent | nullno
disclosure_scripts[].created_atstring (date-time)no
claimsobjectnoVersion 2, extended in version 4. Fixed text, identical in every packet: what the product asserts about the collection notice (the agent's declaration) and what it does NOT (that the notice was spoken, heard or understood, or that its text satisfies a rule). The script block (version 4) says the same of the script versions: they were approved by the recorded actor with the recorded reference and reviewer, and the product does not assert that any text satisfies any rule or jurisdiction.
integrityobjectno
integrity.algorithm"sha256"no
integrity.digeststringnoHex sha256 over the canonical envelope without integrity.
integrity.canonicalizationstringno
integrity.worm_attestationobject | nullno
json
{
  "packet": "litigation_defense",
  "packet_version": 5,
  "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": [],
  "suppression_events": [],
  "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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "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).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

List the archived Litigation Defense Packets of a debt ​

http
GET /v1/debts/{id}/defense-packet/archives

The attestations of this debt, newest first. The RFC 3161 token is not inlined: it ships in the attestation kit. Same permission as the GET of the packet.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The attestations (empty list when nothing was archived).

FieldTypeRequiredDescription
archivesarray of DefensePacketArchiveyes
archives[].idstring (uuid)yes
archives[].debt_idstring (uuid)yes
archives[].artifact_sha256stringyesSHA-256 (hex) of the archived ZIP bytes; the digest the authority time-stamped.
archives[].integrity_digeststringyesThe packet's own integrity.digest (hex) at archive time.
archives[].object_keystringyes
archives[].version_idstring,nullnoThe object version the store wrote. Null only for bucket_lock.
archives[].storestringyes
archives[].retention_modeCOMPLIANCE · GOVERNANCE · bucket_lockyes
archives[].retain_untilstring,null (date-time)noRetention end the store confirmed. Null only for bucket_lock, where the bucket rule governs.
archives[].tsa_urlstring (uri)yes
archives[].tsa_gen_timestring (date-time)yesThe time the authority put in the token (genTime).
archives[].created_bystringyes
archives[].created_atstring (date-time)yes

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "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).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Archive the Litigation Defense Packet (WORM store + RFC 3161 timestamp) ​

http
POST /v1/debts/{id}/defense-packet/archives

Builds the packet exactly as GET /v1/debts/{id}/defense-packet?format=zip does, time-stamps the SHA-256 of the ZIP at the configured RFC 3161 authority, writes the ZIP to the retention-locked archive store and reads the retention back before recording ONE append-only attestation row. The row holds what the store ANSWERED on the read-back (retention_mode, retain_until, version_id), never what was asked. Same permission as the GET of the packet. The body is optional and its only key is retention_days; any other key is a 400.

Retention: the store seals the object for the platform default (DEFENSE_RETENTION_DAYS, a deployment value that must be set to turn the feature on). A tenant can only LENGTHEN it: retention_days below the default is a 422, and so is one above DEFENSE_RETENTION_MAX_DAYS (when that is not set the tenant cannot lengthen at all). Nobody can shorten a retention, and changing the default only affects archives created afterwards. The extension only counts for a NEW archive: bytes already archived answer 200 with the existing row and its original retain_until.

Fail-closed: if the store, the authority or the read-back fails, NO row is written and the response is a 503 that says nothing was archived (an object the store accepted before the failure is an orphan that the sweeper removes). Idempotent by CONTENT: archiving bytes already archived for this debt answers 200 with the existing row and does not call the authority or the store again. Two calls whose ZIP differs (the packet is generated at second resolution) are two archives. Capped per debt and UTC day (DEFENSE_ARCHIVE_MAX_PER_DEBT_PER_DAY, default 5): over the cap answers 429 with Retry-After until UTC midnight. Every emission writes one entry to the tenant audit log.

With DEFENSE_ARCHIVE_ENABLED off, the three archive routes answer the same 404 as a route that does not exist.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Request body (JSON)

FieldTypeRequiredDescription
retention_daysintegernoRetention to seal this archive with, in days. At least the platform default and at most the platform maximum; omitted means the default.
json
{
  "retention_days": 365
}

Responses

200 ​

These exact bytes were already archived; the existing attestation.

FieldTypeRequiredDescription
archiveobject (DefensePacketArchive)yesThe append-only attestation of ONE archived packet. retention_mode, retain_until and version_id are what the archive store answered when the object was read back after the write.
archive.idstring (uuid)yes
archive.debt_idstring (uuid)yes
archive.artifact_sha256stringyesSHA-256 (hex) of the archived ZIP bytes; the digest the authority time-stamped.
archive.integrity_digeststringyesThe packet's own integrity.digest (hex) at archive time.
archive.object_keystringyes
archive.version_idstring,nullnoThe object version the store wrote. Null only for bucket_lock.
archive.storestringyes
archive.retention_modeCOMPLIANCE · GOVERNANCE · bucket_lockyes
archive.retain_untilstring,null (date-time)noRetention end the store confirmed. Null only for bucket_lock, where the bucket rule governs.
archive.tsa_urlstring (uri)yes
archive.tsa_gen_timestring (date-time)yesThe time the authority put in the token (genTime).
archive.created_bystringyes
archive.created_atstring (date-time)yes

201 ​

Archived; the new attestation.

FieldTypeRequiredDescription
archiveobject (DefensePacketArchive)yesThe append-only attestation of ONE archived packet. retention_mode, retain_until and version_id are what the archive store answered when the object was read back after the write.
archive.idstring (uuid)yes
archive.debt_idstring (uuid)yes
archive.artifact_sha256stringyesSHA-256 (hex) of the archived ZIP bytes; the digest the authority time-stamped.
archive.integrity_digeststringyesThe packet's own integrity.digest (hex) at archive time.
archive.object_keystringyes
archive.version_idstring,nullnoThe object version the store wrote. Null only for bucket_lock.
archive.storestringyes
archive.retention_modeCOMPLIANCE · GOVERNANCE · bucket_lockyes
archive.retain_untilstring,null (date-time)noRetention end the store confirmed. Null only for bucket_lock, where the bucket rule governs.
archive.tsa_urlstring (uri)yes
archive.tsa_gen_timestring (date-time)yesThe time the authority put in the token (genTime).
archive.created_bystringyes
archive.created_atstring (date-time)yes

400 — BadRequest ​

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "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).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

422 — Unprocessable ​

Shape is fine, semantics are not (broken domain rule).

json
{
  "error": {
    "code": "unprocessable",
    "message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
  }
}

429 ​

The per-debt daily archive cap is reached (code archive_daily_cap). Retry-After is the number of seconds until UTC midnight. This operation can ALSO answer the per-key quota 429 (rate_limited).

json
{
  "error": {
    "code": "archive_daily_cap",
    "message": "the daily archive cap for this debt is reached; retry after the interval in Retry-After"
  }
}

503 ​

Nothing was archived (code archive_unavailable): archiving is not configured (retention period, store or time-stamp authority unset), or the authority, the store or the retention read-back failed. Safe to retry.

json
{
  "error": {
    "code": "archive_unavailable",
    "message": "the archive store did not confirm the retention; nothing was archived"
  }
}

Attestation kit of an archived Litigation Defense Packet ​

http
GET /v1/debts/{id}/defense-packet/archives/{aid}/attestation

Everything a third party needs to check the archive without trusting this platform: the attestation row (attestation.json), the RFC 3161 time-stamp as a base64 DER TimeStampResp (artifact.tsr once decoded) and a short-lived pre-signed URL of the archived ZIP (the exact version_id). To verify: sha256sum of the downloaded ZIP must equal attestation.artifact_sha256, and openssl ts -verify -digest <that sha256> -in artifact.tsr must accept it against the authority chain. The URL is minted per request and expires at artifact.expires_at. Same permission as the GET of the packet.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
aidpathstring (uuid)yesThe attestation id (archives[].id).

Responses

200 ​

The attestation kit.

FieldTypeRequiredDescription
attestationobject (DefensePacketArchive)yesThe append-only attestation of ONE archived packet. retention_mode, retain_until and version_id are what the archive store answered when the object was read back after the write.
attestation.idstring (uuid)yes
attestation.debt_idstring (uuid)yes
attestation.artifact_sha256stringyesSHA-256 (hex) of the archived ZIP bytes; the digest the authority time-stamped.
attestation.integrity_digeststringyesThe packet's own integrity.digest (hex) at archive time.
attestation.object_keystringyes
attestation.version_idstring,nullnoThe object version the store wrote. Null only for bucket_lock.
attestation.storestringyes
attestation.retention_modeCOMPLIANCE · GOVERNANCE · bucket_lockyes
attestation.retain_untilstring,null (date-time)noRetention end the store confirmed. Null only for bucket_lock, where the bucket rule governs.
attestation.tsa_urlstring (uri)yes
attestation.tsa_gen_timestring (date-time)yesThe time the authority put in the token (genTime).
attestation.created_bystringyes
attestation.created_atstring (date-time)yes
tsrstring (byte)yesThe RFC 3161 time-stamp as a DER TimeStampResp (status granted), base64.
artifactobjectyes
artifact.urlstring (uri)yes
artifact.expires_atstring (date-time)yes

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

json
{
  "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).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

503 ​

No archive store is configured, so no URL can be minted (code archive_unavailable).

json
{
  "error": {
    "code": "archive_unavailable",
    "message": "archiving is not configured: no archive store is set; nothing was archived"
  }
}

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