Skip to content

Call attempts (CDR) ​

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

Append-only CDR query + typed dispositions.

Endpoints ​

MethodPathSummary
GET/v1/call_attemptsFiltered, keyset-paginated CDR query
POST/v1/call_attempts/{id}/dispositionTyped agent/AI outcome (+promise for PTP)
GET/v1/call_attempts/{id}/analysisAI summary and suggested disposition of a call
POST/v1/call_attempts/{id}/disclosureAgent's attestation of the collection notice

Filtered, keyset-paginated CDR query ​

http
GET /v1/call_attempts

Rows are append-only CDR truth: originals have corrects_id: null; corrections point at the original — the LATEST correction is the final disposition. compliance_snapshot carries the frozen decision evidence. disclosure.script (core#1690, ADR #219 Decision 4) is the approved collection-notice script that applies to the call, or null; the station reads it and computes nothing.

Parameters

NameInTypeRequiredDescription
campaign_idquerystring (uuid)no
debt_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.
json
{
  "call_attempts": [
    {
      "id": "8c7d6e5f-4a3b-2c1d-0e9f-8a7b6c5d4e3f",
      "tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
      "debt_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
      "campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
      "agent_id": null,
      "consumer_ref": "cms-consumer-9001",
      "debt_key": "debt:cms-debt-0001",
      "call_uuid": "6f0a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c",
      "from_number": "+13125550199",
      "to_number": "+13125551001",
      "started_at": "2026-07-05T15:00:00Z",
      "answered_at": null,
      "ended_at": null,
      "disposition": null,
      "hangup_cause": null,
      "sip_response_code": null,
      "compliance_snapshot": {
        "decision_id": "2c1d0e9f-8a7b-6c5d-4e3f-2a1b0c9d8e7f",
        "policy_version": "usa-cell.v1"
      },
      "amd_verdict": null,
      "corrects_id": null,
      "inserted_at": "2026-07-05T15:00:00Z"
    }
  ],
  "next_cursor": null
}

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

Typed agent/AI outcome (+promise for PTP) ​

http
POST /v1/call_attempts/{id}/disposition

Records an outcome for a finished attempt as a compensating CDR record (the table stays append-only). {id} must be an ORIGINAL row (corrects_id: null); corrections and unknown ids are 404. Typed contract is bidirectional: promise_to_pay REQUIRES the promise payload; a promise payload on any other disposition → 422. Correction + promise commit in ONE transaction; a PTP also advances the debt's lead state to promise_to_pay.

An unrecognized field INSIDE promise is a 400 naming the key, and nothing is appended to the CDR. Scope, stated because the difference is easy to misread: only promise is strict. Unknown fields at the TOP level of this body are still ignored, since the top level is read key by key rather than scrubbed against a whitelist.

note is the agent's free-text note about the CALL and is accepted on EVERY disposition, not only promise_to_pay. Do not confuse it with promise.note, which describes the TERMS of the agreement and lands on the promise record; both may travel in the same request and are stored separately. The note comes back on call_attempt.note here and on every other call-attempt surface, so the next agent to work the account reads it in the debt's history.

WHO the outcome — and the promise born with it — is attributed to depends on the principal (ADR #133, ADR #134). A dd_ machine key may name any agent of its tenant. A ddu_ login session whose person is linked to an agent seat authors as THAT seat: agent_id may be omitted (filled with the seat) or repeat it; naming another agent is 403 forbidden and nothing is appended — no correction, no promise. A ddw_ may only name its own agent. A ddu_ without a linked seat may name another agent only with users.role = account_admin — a member is 403 role_forbidden, nothing appended — once the tenant's catalog key ddu_author_role_enforced is on (born off: until then the seatless session keeps the dd_ contract).

disclosure (optional) is the agent's attestation about the mandatory collection notice, written in the SAME transaction as the correction (see POST /v1/call_attempts/{id}/disclosure for what it states and what it does not). It is strict like promise: a key outside the list, a malformed supersedes_id, a not_delivered without reason, or no outcome is a 400 and NOTHING is appended — no correction, no attestation. It takes the author of the disposition, so the object does not repeat agent_id; a request that attests with no agent named is 400. Without the object the response has no disclosure key and the closing answers exactly what it always did.

ENFORCEMENT, behind the tenant's catalog key disclosure_attestation_enforced (born OFF: until it is on the closing answers exactly what it always did and a contacted call with no attestation reads unattested). With it ON, a call IN SCOPE — one with an effective debt whose pipeline row is answered_human or transferred, decided from the call's facts and never from the code the agent types — that has no attestation (earlier, or in this body's disclosure) is 422 with error.code: disclosure_attestation_required, and NOTHING is written: no correction, no promise, no attestation. A call whose pipeline row does not exist yet cannot be decided and is 409 with error.code: attempt_not_final: retry once the call ends. A call with no debt is not in scope and closes as before. Turning the key OFF only lifts the block; no attestation is ever created by the platform.

suggestion_id (optional) cites the AI suggestion the agent saw (GET /v1/call_attempts/{id}/analysis). It ONLY records that the suggestion was shown and what the agent typed; the outcome is always the disposition in this body, never the suggested one, and the suggestion changes nothing about the retry policy. A malformed id is 400; an id that is not this attempt's analysis is 404 and NOTHING is appended. The same suggestion cited twice records one adoption.

Once recorded, the disposition is also handed to the tenant's retry policy (/v1/disposition-rules), AFTER the outcome committed: a policy failure never undoes the outcome, and the response does not report the policy's effects. The retry is measured from the ATTEMPT (the instant it closed), not from when this request arrives, so repeating the request writes the same instant. The policy can only postpone or close the debt: it never brings a parked debt forward, never touches a debt outside the dialable states, and does not reschedule a debt with a pending callback. Every dial is still gated by Reg F at originate time. A tenant with no rule for the code sees no change.

Parameters

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

Request body (JSON, required)

FieldTypeRequiredDescription
dispositionstringyesNon-empty typed outcome, e.g. promise_to_pay, wrong_number.
agent_idstring (uuid)noThe disposing agent. Optional; for a ddu_ session linked to a seat it must be that seat (or be omitted).
notestring | nullnoFree-text agent note about the call, accepted on ANY disposition. Encrypted at rest (per-tenant envelope) and never queried by content. Omit it (or send null) for no note; "" is stored as no note.
promiseobjectnoREQUIRED iff disposition is promise_to_pay.
promise.amount_centsintegeryes
promise.currencyMXN · USDyes
promise.promised_datestring (date)yesFuture date, at most the tenant's promise_max_horizon_days out (90 by default).
promise.notestringno
disclosureDisclosureAttestation | nullnoThe agent's attestation about the collection notice for this call, written with the correction in one transaction. Omit it (or send null) to write none.
suggestion_idstring (uuid) | nullnoThe id of the AI analysis whose suggestion the agent saw. Only records the adoption; it never chooses or changes the outcome.

simple

json
{
  "disposition": "wrong_number"
}

citing_the_ai_suggestion

json
{
  "disposition": "promise_to_pay",
  "suggestion_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "promise": {
    "amount_cents": 12500,
    "currency": "USD",
    "promised_date": "2026-07-21"
  }
}

with_disclosure

json
{
  "disposition": "answered_human",
  "agent_id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "disclosure": {
    "outcome": "delivered"
  }
}

with_note

json
{
  "disposition": "answered_third_party",
  "note": "his wife answered, he gets home at 7pm"
}

promise_to_pay

json
{
  "disposition": "promise_to_pay",
  "agent_id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "promise": {
    "amount_cents": 12500,
    "currency": "USD",
    "promised_date": "2026-07-21",
    "note": "two installments"
  }
}

Responses

201 ​

The compensating CDR record (+ the promise for PTP, + the attestation when one was sent).

FieldTypeRequiredDescription
call_attemptobject (CallAttempt)yesAppend-only CDR row. Originals have corrects_id: null; corrections point at the original — the LATEST correction is the final disposition.
call_attempt.idstring (uuid)no
call_attempt.tenant_idstring (uuid)no
call_attempt.debt_idstring (uuid)no
call_attempt.campaign_idstring (uuid) | nullno
call_attempt.agent_idstring (uuid) | nullno
call_attempt.consumer_refstringno
call_attempt.debt_keystringno
call_attempt.call_uuidstring | nullno
call_attempt.from_numberstring | nullno
call_attempt.to_numberstringno
call_attempt.started_atstring (date-time) | nullno
call_attempt.answered_atstring (date-time) | nullno
call_attempt.ended_atstring (date-time) | nullno
call_attempt.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_attempt.hangup_causestring | nullno
call_attempt.sip_response_codeinteger | nullno
call_attempt.compliance_snapshotobject | nullnoFrozen decision evidence at originate time.
call_attempt.amd_verdictstring | nullno
call_attempt.recording_uristring | nullnoWhere the call recording landed (object path/URI); null when the call was not recorded.
call_attempt.ai_usedbooleannoWhether the voice-AI bot ran on this call (the AI add-on meter key).
call_attempt.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_attempt.corrects_idstring (uuid) | nullno
call_attempt.inserted_atstring (date-time)no
call_attempt.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_attempt.disclosure.statusattested · declared_not_delivered · unattested · not_applicable · undecidableyes
call_attempt.disclosure.event_idstring (uuid) | nullyesThe current attestation (null when there is none).
call_attempt.disclosure.atstring (date-time) | nullyesServer time of the current attestation.
call_attempt.disclosure.revisedbooleanyestrue when the attempt has more than one attestation (one rectifies another).
call_attempt.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_attempt.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.
promisePromise | nullyes
disclosureobject (DisclosureEvent)noOne sealed, append-only attestation. It states what the agent DECLARED: evidence_kind is agent_attestation, never proof that the notice was said or heard.
disclosure.idstring (uuid)no
disclosure.tenant_idstring (uuid)no
disclosure.call_attempt_idstring (uuid)no
disclosure.debt_idstring (uuid)no
disclosure.agent_idstring (uuid)no
disclosure.evidence_kindagent_attestationno
disclosure.outcomedelivered · not_deliveredno
disclosure.reasonstring | nullno
disclosure.notestring | nullno
disclosure.script_sha256string | nullno
disclosure.script_version_idstring (uuid)noPresent only when the attestation is linked to a script library version (core#1690).
disclosure.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.supersedes_idstring (uuid) | nullno
disclosure.occurred_atstring (date-time)no
disclosure.created_atstring (date-time)no

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

json
{
  "error": {
    "code": "forbidden",
    "message": "tenant is suspended"
  }
}

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

409 — Conflict ​

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

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

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 — DispositionThrottled ​

The NR-18 request-rate brake refused this request (retryable next window). code rate_limited. Bounded per most specific DURABLE principal (agent > user > tenant, surviving re-authentication), with a budget of its OWN and narrower than the control verbs': what it bounds is durable row growth, since every accepted disposition INSERTs a correction row into the append-only attempt history. Refused BEFORE the attempt is looked up, so this answer is identical whether or not the id exists. Retry-After is the time left in the fixed window.

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

AI summary and suggested disposition of a call ​

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

The summary of ONE finished call and the disposition the AI SUGGESTS. It is a suggestion, never an outcome: nothing is applied, and the agent confirms by typing the disposition (POST /v1/call_attempts/{id}/disposition, optionally citing suggestion_id = this id).

suggested_code is always a code ACTIVE in the tenant's own catalog, or null with the reason in suggestion_note. suggested_class is that code's class today; a suggestion of class do_not_call is returned as such and a client must not preselect it.

Readable by the agent that owns the attempt (a ddu_ login whose person is linked to the seat, or the ddw_ token bound to that agent) and by a supervisor or admin. A dd_ key is a service and is 403 role_forbidden, as is a login with no seat and no supervisor role. 404 when the attempt has no analysis in this tenant: it belongs to another agent or another tenant, it was never analysed (not eligible, no entitlement, or still pending), its audio was purged (the purge deletes the analysis with it), or the id is malformed. A client treats 404 as "no suggestion yet" and keeps the manual typing.

Parameters

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

Responses

200 ​

The analysis.

FieldTypeRequiredDescription
analysisobject (CallAnalysis)yes
analysis.idstring (uuid)yesCite it as suggestion_id when typing.
analysis.call_attempt_idstring (uuid)yes
analysis.sourcetranscript · ai_conversationyesWhere the text came from, a recording transcript or a voice-AI conversation.
analysis.summarystringyesSummary of the call. Personal data; never logged.
analysis.suggested_codestring,nullnoA code ACTIVE in the tenant catalog, or null (see suggestion_note).
analysis.suggested_classcontacted · no_contact · busy · machine · bad_number · do_not_call · other · nullnoClass of suggested_code in the catalog today. do_not_call must never be preselected.
analysis.suggestion_notesuggested · model_none · not_in_catalog · inactiveyesWhy suggested_code is or is not set.
analysis.confidence_pctinteger,nullnoThe model's confidence; only present with a suggested code.
analysis.providerstringyes
analysis.modelstringyes
analysis.prompt_versionstringyes
analysis.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"
  }
}

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

json
{
  "error": {
    "code": "forbidden",
    "message": "tenant is suspended"
  }
}

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

Agent's attestation of the collection notice ​

http
POST /v1/call_attempts/{id}/disclosure

Records, append-only, what the agent DECLARED about the mandatory collection notice on a call: that it was delivered, or not_delivered with a closed reason. It does NOT prove the notice was said or heard — evidence_kind is agent_attestation and says so on the wire. The row is sealed in the tenant's compliance chain in the same transaction: if the seal fails nothing is written. {id} must be an ORIGINAL attempt (corrects_id: null); a correction, an unknown id or another tenant's attempt is 404. A call with no debt (not even from an assignment) has nothing to attest against: 422.

There is NO default: a body without outcome is 400. An unknown key in the body is 400. reason is required with not_delivered and refused with delivered; note is accepted only with reason: other and is stored encrypted. script_sha256 (lowercase hex sha256 of the notice text the agent read) is optional in v1.

script_version_id (optional, core#1690, ADR #219) links the attestation to the version of the tenant's script library the agent read. core checks that it is a version of THIS tenant, that it was approved and not yet retired when the call started (and not approved after the call ended), and that its selector was a candidate for the call's state and debt type (the facts frozen in the attempt's compliance_snapshot, never today's contact record); then it derives the script_sha256 from the version and the script_resolution (the precedence branch the version's selector belongs to: exact, state, debt_type or tenant_default) and seals both with the attestation. A version replaced or retired AFTER the call started is still valid: what is attested is what the agent saw, not what would resolve today. A script_version_id that is not a UUID, is another tenant's, a draft, does not qualify for this call, or comes with a script_sha256 that is not its digest is 422 with error.code: disclosure_script_mismatch, and NOTHING is written. script_resolution is never accepted from the caller (400). Without script_version_id the attestation is exactly what it always was, and the response has no script_version_id or script_resolution key. With the tenant's disclosure_script_enforced flag on and at least one approved version, a call in scope must name its script: without script_version_id it is 422 with error.code: disclosure_script_required; when no approved script resolves for the call (none or undecidable) it is 409 with error.code: disclosure_script_unresolvable; nothing is written in either case. The same field is accepted inside the disclosure object of POST /v1/call_attempts/{id}/disposition, where a mismatch also leaves the correction unwritten.

One «first» attestation per call: sending a second one without supersedes_id is 409. To rectify, send a new attestation whose supersedes_id is the CURRENT one of the same call; nothing is edited or deleted. Rectifying one that is not the current one is 409; a malformed supersedes_id is 400.

WHO attests follows the same rule as POST /v1/call_attempts/{id}/disposition: a dd_ machine key must name the agent in agent_id (none → 400bad_request, nobody to attribute it to); a ddu_ session linked to an agent seat signs as THAT seat, and naming another agent is 403; a ddw_ signs only as its own agent and only a call of its own (403 otherwise), and nothing is written. Shares the disposition rate brake (429).

Parameters

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

Request body (JSON, required)

delivered

json
{
  "agent_id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "outcome": "delivered"
}

not_delivered

json
{
  "agent_id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "outcome": "not_delivered",
  "reason": "consumer_hung_up"
}

Responses

201 ​

The sealed attestation.

FieldTypeRequiredDescription
disclosureobject (DisclosureEvent)yesOne sealed, append-only attestation. It states what the agent DECLARED: evidence_kind is agent_attestation, never proof that the notice was said or heard.
disclosure.idstring (uuid)no
disclosure.tenant_idstring (uuid)no
disclosure.call_attempt_idstring (uuid)no
disclosure.debt_idstring (uuid)no
disclosure.agent_idstring (uuid)no
disclosure.evidence_kindagent_attestationno
disclosure.outcomedelivered · not_deliveredno
disclosure.reasonstring | nullno
disclosure.notestring | nullno
disclosure.script_sha256string | nullno
disclosure.script_version_idstring (uuid)noPresent only when the attestation is linked to a script library version (core#1690).
disclosure.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.supersedes_idstring (uuid) | nullno
disclosure.occurred_atstring (date-time)no
disclosure.created_atstring (date-time)no

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

json
{
  "error": {
    "code": "forbidden",
    "message": "tenant is suspended"
  }
}

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

409 — Conflict ​

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

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

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 — DispositionThrottled ​

The NR-18 request-rate brake refused this request (retryable next window). code rate_limited. Bounded per most specific DURABLE principal (agent > user > tenant, surviving re-authentication), with a budget of its OWN and narrower than the control verbs': what it bounds is durable row growth, since every accepted disposition INSERTs a correction row into the append-only attempt history. Refused BEFORE the attempt is looked up, so this answer is identical whether or not the id exists. Retry-After is the time left in the fixed window.

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

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