Skip to content

Call attempts (CDR)

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

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)

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.

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 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.
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|busy|no_answer|canceled|rejected|failed; typed agent/AI outcomes are free-form (e.g. promise_to_pay).
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
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).

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

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.

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)no
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, max 90 days out.
promise.notestringno

simple

json
{
  "disposition": "wrong_number"
}

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

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|busy|no_answer|canceled|rejected|failed; typed agent/AI outcomes are free-form (e.g. promise_to_pay).
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
promisePromise | nullyes

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

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

403 — Forbidden

The tenant is suspended.

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

422 — Unprocessable

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

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

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.