Appearance
Call attempts (CDR)
Generated from
DialerDigital/corecommit0fc18008b3c8— specopenapi/dialer-v1.yamlv0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
Append-only CDR query + typed dispositions.
Endpoints
| Method | Path | Summary |
|---|---|---|
GET | /v1/call_attempts | Filtered, keyset-paginated CDR query |
POST | /v1/call_attempts/{id}/disposition | Typed agent/AI outcome (+promise for PTP) |
GET | /v1/call_attempts/{id}/analysis | AI summary and suggested disposition of a call |
POST | /v1/call_attempts/{id}/disclosure | Agent's attestation of the collection notice |
Filtered, keyset-paginated CDR query
http
GET /v1/call_attemptsRows 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
campaign_id | query | string (uuid) | no | |
debt_id | query | string (uuid) | no | |
agent_id | query | string (uuid) | no | |
disposition | query | string | no | Exact match against an OPEN vocabulary: call_attempts.disposition is written VERBATIM (20260725000003_create_disposition_engine.exs) and the 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. |
from | query | string (date-time) | no | RFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted). |
to | query | string (date-time) | no | RFC 3339 upper bound. |
limit | query | integer | no | Page size (default 100, max 1000). |
cursor | query | string | no | Opaque keyset cursor from the previous page's next_cursor. |
Responses
200
One page of CDR rows.
| Field | Type | Required | Description |
|---|---|---|---|
call_attempts | array of CallAttempt | yes | |
call_attempts[].id | string (uuid) | no | |
call_attempts[].tenant_id | string (uuid) | no | |
call_attempts[].debt_id | string (uuid) | no | |
call_attempts[].campaign_id | string (uuid) | null | no | |
call_attempts[].agent_id | string (uuid) | null | no | |
call_attempts[].consumer_ref | string | no | |
call_attempts[].debt_key | string | no | |
call_attempts[].call_uuid | string | null | no | |
call_attempts[].from_number | string | null | no | |
call_attempts[].to_number | string | no | |
call_attempts[].started_at | string (date-time) | null | no | |
call_attempts[].answered_at | string (date-time) | null | no | |
call_attempts[].ended_at | string (date-time) | null | no | |
call_attempts[].disposition | string | null | no | Engine dispositions: answered_human|abandoned|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_cause | string | null | no | |
call_attempts[].sip_response_code | integer | null | no | |
call_attempts[].compliance_snapshot | object | null | no | Frozen decision evidence at originate time. |
call_attempts[].amd_verdict | string | null | no | |
call_attempts[].recording_uri | string | null | no | Where the call recording landed (object path/URI); null when the call was not recorded. |
call_attempts[].ai_used | boolean | no | Whether the voice-AI bot ran on this call (the AI add-on meter key). |
call_attempts[].note | string | null | no | The agent's free-text note about the call, entered at disposition time on ANY disposition. It rides on the correction record that carries the disposition, so the ORIGINAL row is normally null. Encrypted at rest (per-tenant envelope) and never queried by content. null = no note, or a record written before the column existed. |
call_attempts[].corrects_id | string (uuid) | null | no | |
call_attempts[].inserted_at | string (date-time) | no | |
call_attempts[].disclosure | object (CallAttemptDisclosure) | no | What 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.status | attested · declared_not_delivered · unattested · not_applicable · undecidable | yes | |
call_attempts[].disclosure.event_id | string (uuid) | null | yes | The current attestation (null when there is none). |
call_attempts[].disclosure.at | string (date-time) | null | yes | Server time of the current attestation. |
call_attempts[].disclosure.revised | boolean | yes | true when the attempt has more than one attestation (one rectifies another). |
call_attempts[].disclosure.script | CallAttemptDisclosureScript | null | no | Only 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_match | matched · other_version · unscripted · not_applicable | no | Only 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_cursor | string | null | yes | Opaque 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}/dispositionRecords 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
disposition | string | yes | Non-empty typed outcome, e.g. promise_to_pay, wrong_number. |
agent_id | string (uuid) | no | The disposing agent. Optional; for a ddu_ session linked to a seat it must be that seat (or be omitted). |
note | string | null | no | Free-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. |
promise | object | no | REQUIRED iff disposition is promise_to_pay. |
promise.amount_cents | integer | yes | |
promise.currency | MXN · USD | yes | |
promise.promised_date | string (date) | yes | Future date, at most the tenant's promise_max_horizon_days out (90 by default). |
promise.note | string | no | |
disclosure | DisclosureAttestation | null | no | The 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_id | string (uuid) | null | no | The 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).
| Field | Type | Required | Description |
|---|---|---|---|
call_attempt | object (CallAttempt) | yes | Append-only CDR row. Originals have corrects_id: null; corrections point at the original — the LATEST correction is the final disposition. |
call_attempt.id | string (uuid) | no | |
call_attempt.tenant_id | string (uuid) | no | |
call_attempt.debt_id | string (uuid) | no | |
call_attempt.campaign_id | string (uuid) | null | no | |
call_attempt.agent_id | string (uuid) | null | no | |
call_attempt.consumer_ref | string | no | |
call_attempt.debt_key | string | no | |
call_attempt.call_uuid | string | null | no | |
call_attempt.from_number | string | null | no | |
call_attempt.to_number | string | no | |
call_attempt.started_at | string (date-time) | null | no | |
call_attempt.answered_at | string (date-time) | null | no | |
call_attempt.ended_at | string (date-time) | null | no | |
call_attempt.disposition | string | null | no | Engine 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_cause | string | null | no | |
call_attempt.sip_response_code | integer | null | no | |
call_attempt.compliance_snapshot | object | null | no | Frozen decision evidence at originate time. |
call_attempt.amd_verdict | string | null | no | |
call_attempt.recording_uri | string | null | no | Where the call recording landed (object path/URI); null when the call was not recorded. |
call_attempt.ai_used | boolean | no | Whether the voice-AI bot ran on this call (the AI add-on meter key). |
call_attempt.note | string | null | no | The agent's free-text note about the call, entered at disposition time on ANY disposition. It rides on the correction record that carries the disposition, so the ORIGINAL row is normally null. Encrypted at rest (per-tenant envelope) and never queried by content. null = no note, or a record written before the column existed. |
call_attempt.corrects_id | string (uuid) | null | no | |
call_attempt.inserted_at | string (date-time) | no | |
call_attempt.disclosure | object (CallAttemptDisclosure) | no | What 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.status | attested · declared_not_delivered · unattested · not_applicable · undecidable | yes | |
call_attempt.disclosure.event_id | string (uuid) | null | yes | The current attestation (null when there is none). |
call_attempt.disclosure.at | string (date-time) | null | yes | Server time of the current attestation. |
call_attempt.disclosure.revised | boolean | yes | true when the attempt has more than one attestation (one rectifies another). |
call_attempt.disclosure.script | CallAttemptDisclosureScript | null | no | Only 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_match | matched · other_version · unscripted · not_applicable | no | Only 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. |
promise | Promise | null | yes | |
disclosure | object (DisclosureEvent) | no | One 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.id | string (uuid) | no | |
disclosure.tenant_id | string (uuid) | no | |
disclosure.call_attempt_id | string (uuid) | no | |
disclosure.debt_id | string (uuid) | no | |
disclosure.agent_id | string (uuid) | no | |
disclosure.evidence_kind | agent_attestation | no | |
disclosure.outcome | delivered · not_delivered | no | |
disclosure.reason | string | null | no | |
disclosure.note | string | null | no | |
disclosure.script_sha256 | string | null | no | |
disclosure.script_version_id | string (uuid) | no | Present only when the attestation is linked to a script library version (core#1690). |
disclosure.script_resolution | exact · state · debt_type · tenant_default | no | Present only with script_version_id; the precedence branch the version's selector belongs to for this call. |
disclosure.supersedes_id | string (uuid) | null | no | |
disclosure.occurred_at | string (date-time) | no | |
disclosure.created_at | string (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}/analysisThe 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The analysis.
| Field | Type | Required | Description |
|---|---|---|---|
analysis | object (CallAnalysis) | yes | |
analysis.id | string (uuid) | yes | Cite it as suggestion_id when typing. |
analysis.call_attempt_id | string (uuid) | yes | |
analysis.source | transcript · ai_conversation | yes | Where the text came from, a recording transcript or a voice-AI conversation. |
analysis.summary | string | yes | Summary of the call. Personal data; never logged. |
analysis.suggested_code | string,null | no | A code ACTIVE in the tenant catalog, or null (see suggestion_note). |
analysis.suggested_class | contacted · no_contact · busy · machine · bad_number · do_not_call · other · null | no | Class of suggested_code in the catalog today. do_not_call must never be preselected. |
analysis.suggestion_note | suggested · model_none · not_in_catalog · inactive | yes | Why suggested_code is or is not set. |
analysis.confidence_pct | integer,null | no | The model's confidence; only present with a suggested code. |
analysis.provider | string | yes | |
analysis.model | string | yes | |
analysis.prompt_version | string | yes | |
analysis.created_at | string (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}/disclosureRecords, 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource 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.
| Field | Type | Required | Description |
|---|---|---|---|
disclosure | object (DisclosureEvent) | yes | One 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.id | string (uuid) | no | |
disclosure.tenant_id | string (uuid) | no | |
disclosure.call_attempt_id | string (uuid) | no | |
disclosure.debt_id | string (uuid) | no | |
disclosure.agent_id | string (uuid) | no | |
disclosure.evidence_kind | agent_attestation | no | |
disclosure.outcome | delivered · not_delivered | no | |
disclosure.reason | string | null | no | |
disclosure.note | string | null | no | |
disclosure.script_sha256 | string | null | no | |
disclosure.script_version_id | string (uuid) | no | Present only when the attestation is linked to a script library version (core#1690). |
disclosure.script_resolution | exact · state · debt_type · tenant_default | no | Present only with script_version_id; the precedence branch the version's selector belongs to for this call. |
disclosure.supersedes_id | string (uuid) | null | no | |
disclosure.occurred_at | string (date-time) | no | |
disclosure.created_at | string (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"
}
}