Call attempts (CDR)
Generated from
DialerDigital/corecommit6e9f026f0a72— specopenapi/dialer-v1.yamlv0.36.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
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) |
Filtered, keyset-paginated CDR query
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.
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 seven engine values — answered_human, abandoned, busy, no_answer, canceled, rejected, failed — are a NAMED SUBSET of it, not its boundary: typed agent/AI outcomes (promise_to_pay, wrong_number) and platform markers (not_placed) are equally valid. An unknown code answers 200 with zero rows, never 400. What IS 400 is a malformed SHAPE: the empty string, and the parameter repeated or sent in array form. |
from | query | string (date-time) | no | RFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted). |
to | query | string (date-time) | no | RFC 3339 upper bound. |
limit | query | integer | no | Page size (default 100, max 1000). |
cursor | query | string | no | Opaque keyset cursor from the previous page's next_cursor. |
Responses
200
One page of CDR rows.
| Field | Type | Required | Description |
|---|---|---|---|
call_attempts | array of CallAttempt | yes | |
call_attempts[].id | string (uuid) | no | |
call_attempts[].tenant_id | string (uuid) | no | |
call_attempts[].debt_id | string (uuid) | no | |
call_attempts[].campaign_id | string (uuid) | null | no | |
call_attempts[].agent_id | string (uuid) | null | no | |
call_attempts[].consumer_ref | string | no | |
call_attempts[].debt_key | string | no | |
call_attempts[].call_uuid | string | null | no | |
call_attempts[].from_number | string | null | no | |
call_attempts[].to_number | string | no | |
call_attempts[].started_at | string (date-time) | null | no | |
call_attempts[].answered_at | string (date-time) | null | no | |
call_attempts[].ended_at | string (date-time) | null | no | |
call_attempts[].disposition | string | null | no | Engine dispositions: answered_human|abandoned|busy|no_answer|canceled|rejected|failed; typed agent/AI outcomes are free-form (e.g. promise_to_pay). |
call_attempts[].hangup_cause | string | null | no | |
call_attempts[].sip_response_code | integer | null | no | |
call_attempts[].compliance_snapshot | object | null | no | Frozen decision evidence at originate time. |
call_attempts[].amd_verdict | string | null | no | |
call_attempts[].recording_uri | string | null | no | Where the call recording landed (object path/URI); null when the call was not recorded. |
call_attempts[].ai_used | boolean | no | Whether the voice-AI bot ran on this call (the AI add-on meter key). |
call_attempts[].note | string | null | no | The agent's free-text note about the call, entered at disposition time on ANY disposition. It rides on the correction record that carries the disposition, so the ORIGINAL row is normally null. Encrypted at rest (per-tenant envelope) and never queried by content. null = no note, or a record written before the column existed. |
call_attempts[].corrects_id | string (uuid) | null | no | |
call_attempts[].inserted_at | string (date-time) | no | |
next_cursor | string | null | yes | Opaque keyset cursor; null on the last page. |
{
"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).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}Typed agent/AI outcome (+promise for PTP)
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.
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 | |
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, max 90 days out. |
promise.note | string | no |
simple
{
"disposition": "wrong_number"
}with_note
{
"disposition": "answered_third_party",
"note": "his wife answered, he gets home at 7pm"
}promise_to_pay
{
"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).
| 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|busy|no_answer|canceled|rejected|failed; typed agent/AI outcomes are free-form (e.g. promise_to_pay). |
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 | |
promise | Promise | null | yes |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"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).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
{
"error": {
"code": "unprocessable",
"message": "scheduled_at must be at least 10 minutes out"
}
}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. No Retry-After header.
{
"error": {
"code": "rate_limited",
"message": "disposition rate limit exceeded; retry later"
}
}