Skip to content

Reports ​

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

Packaged compliance artifacts.

Endpoints ​

MethodPathSummary
GET/v1/reports/cdrCDR as canonical-protojson dialer.v1.CallDetailRecord
GET/v1/report-catalogReport builder catalog (dimensions, metrics, limits)
POST/v1/reports/runRun one aggregate, or one page of attempts, of the report builder
GET/v1/report-definitionsThe tenant's saved report definitions
POST/v1/report-definitionsSave a report definition
GET/v1/report-definitions/{id}One saved report definition
PUT/v1/report-definitions/{id}Replace a report definition
DELETE/v1/report-definitions/{id}Delete a report definition
GET/v1/reports/attemptsExport attempt history (Reg F)
GET/v1/reports/violations-preventedViolations-Prevented compliance artifact

CDR as canonical-protojson dialer.v1.CallDetailRecord ​

http
GET /v1/reports/cdr

The rows of GET /v1/call_attempts — same filters (campaign_id, debt_id, agent_id, disposition, from, to), same limit (default 100, max 1000), same opaque cursor, same tenant isolation — each one projected onto the dialer.v1.CallDetailRecord message of the contracts repo and serialized as canonical protojson: lowerCamelCase field names, enums by name (DISPOSITION_ANSWERED_HUMAN), timestamps as RFC 3339 UTC, and every field at its proto3 default OMITTED (a call never answered has no answeredAt). The page itself follows the same rules: nextCursor is absent on the last page. Generated protojson parsers of contracts decode each element of callDetailRecords.

One row is one record. The ledger is append-only, so an attempt is its original row plus the corrections appended to it, and each row is projected on its own, exactly as /v1/call_attempts pages them; collapsing an attempt into a single final record is not done here.

Only what /v1/call_attempts already publishes is projected. Fields of the message with no source there (direction, mediaPath, carrier, carrierTrunkId, attestation, costWeight, fsNode, sipEdgeNode, originationSource, transferredToCallId) are omitted, and cell too. callId is the channel UUID (call_uuid), or the attempt id when the row never reached a channel. consumerId is the consumer_ref.

disposition filters in the REST spelling (answered_human), as on /v1/call_attempts; the field of the record carries the enum name. The REST vocabulary is open and the contract enum is closed: a code the enum does not list (abandoned, ai_conversation, ...) leaves disposition omitted (DISPOSITION_UNSPECIFIED) and the raw code travels in labels.disposition_code.

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

FieldTypeRequiredDescription
callDetailRecordsarray of CallDetailRecordno
callDetailRecords[].callIdstringno
callDetailRecords[].tenantIdstringno
callDetailRecords[].debtIdstringno
callDetailRecords[].consumerIdstringno
callDetailRecords[].campaignIdstringno
callDetailRecords[].directionDIRECTION_UNSPECIFIED · DIRECTION_OUTBOUND · DIRECTION_INBOUND · DIRECTION_INTERNALno
callDetailRecords[].fromNumberstringno
callDetailRecords[].toNumberstringno
callDetailRecords[].startedAtstring (date-time)no
callDetailRecords[].answeredAtstring (date-time)no
callDetailRecords[].endedAtstring (date-time)no
callDetailRecords[].durationSecondsinteger (uint32)no
callDetailRecords[].billsecinteger (uint32)no
callDetailRecords[].dispositionDISPOSITION_UNSPECIFIED · DISPOSITION_ANSWERED_HUMAN · DISPOSITION_ANSWERED_MACHINE · DISPOSITION_BUSY · DISPOSITION_NO_ANSWER · DISPOSITION_FAILED · DISPOSITION_REJECTED · DISPOSITION_CANCELED · DISPOSITION_BLOCKED_COMPLIANCE · DISPOSITION_VOICEMAIL_DROP · DISPOSITION_TRANSFERREDno
callDetailRecords[].hangupCausestringno
callDetailRecords[].sipResponseCodeinteger (uint32)no
callDetailRecords[].recordingUristringno
callDetailRecords[].aiUsedbooleanno
callDetailRecords[].mediaPathMEDIA_PATH_UNSPECIFIED · MEDIA_PATH_RTPENGINE_KERNEL · MEDIA_PATH_FREESWITCHno
callDetailRecords[].carrierstringno
callDetailRecords[].carrierTrunkIdstringno
callDetailRecords[].attestationATTESTATION_UNSPECIFIED · ATTESTATION_A · ATTESTATION_B · ATTESTATION_C · ATTESTATION_NONEno
callDetailRecords[].costWeightnumber (double)no
callDetailRecords[].cellstringno
callDetailRecords[].fsNodestringno
callDetailRecords[].sipEdgeNodestringno
callDetailRecords[].agentIdstringno
callDetailRecords[].complianceDecisionIdstringno
callDetailRecords[].recordVersioninteger (uint32)no
callDetailRecords[].labelsobjectnodisposition_code carries a REST disposition the Disposition enum does not list.
callDetailRecords[].originationSourceORIGINATION_SOURCE_UNSPECIFIED · ORIGINATION_SOURCE_CAMPAIGN_AUTO · ORIGINATION_SOURCE_AGENT_PREVIEW · ORIGINATION_SOURCE_AGENT_MANUAL · ORIGINATION_SOURCE_API_CTI · ORIGINATION_SOURCE_AI · ORIGINATION_SOURCE_WEB_REQUESTno
callDetailRecords[].transferredToCallIdstringno
nextCursorstringnoOpaque keyset cursor; absent on the last page.
json
{
  "callDetailRecords": [
    {
      "callId": "6f0a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c",
      "tenantId": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
      "debtId": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
      "consumerId": "cms-consumer-9001",
      "campaignId": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
      "fromNumber": "+13125550199",
      "toNumber": "+13125551001",
      "startedAt": "2026-07-05T15:00:00Z",
      "answeredAt": "2026-07-05T15:00:07.250Z",
      "endedAt": "2026-07-05T15:02:31Z",
      "durationSeconds": 151,
      "billsec": 144,
      "disposition": "DISPOSITION_ANSWERED_HUMAN",
      "hangupCause": "NORMAL_CLEARING",
      "recordVersion": 1,
      "complianceDecisionId": "2c1d0e9f-8a7b-6c5d-4e3f-2a1b0c9d8e7f"
    }
  ],
  "nextCursor": "MjAyNi0wNy0wNVQxNTowMDowMFp8OGM3ZDZlNWY"
}

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

Report builder catalog (dimensions, metrics, limits) ​

http
GET /v1/report-catalog

The CLOSED vocabulary of the report builder (core#1964, phase 1): the dimensions and metrics a tenant can ask of ONE fact, call_attempts, with the limits POST /v1/reports/run enforces. Read-only; it describes the vocabulary and touches no tenant data. It keeps answering when the tenant's report_builder_enabled flag is off.

The unit is the ATTEMPT, not the row: the original dial that the platform actually placed plus the corrections appended to it. disposition is that of the LAST settling correction (in_flight while there is none). agent_id follows assignment attribution (the original's agent, else the latest correction that names one). Each metric has ONE definition, shared with the fixed counters of GET /v1/stats/summary.

pii is null on every published dimension: no dimension carries personal data (the phone number and the free-text note are not dimensions).

detail_columns (core#1987) is the vocabulary of mode: detail: one row per attempt. A column whose pii is phone or identifier is MASKED by default (*** + the last four characters, read from the stored mask without decrypting anything); unmask: true on POST /v1/reports/run returns it whole, for a dd_ key or a login with the account_admin role only, and leaves an evidence row. The free-text note and compliance_snapshot are not columns.

Responses

200 ​

The catalog.

FieldTypeRequiredDescription
fact"call_attempts"yes
versionintegeryesBumps when a published definition changes.
dimensionsarray of objectyes
dimensions[].keystringyes
dimensions[].typeuuid · string · boolean · dateyes
dimensions[].piistring | nullyesnull on every published dimension: none carries personal data.
dimensions[].descriptionstringyes
metricsarray of objectyes
metrics[].keystringyes
metrics[].unitcount · seconds · basis_pointsyes
metrics[].descriptionstringyes
detail_columnsarray of objectyesThe vocabulary of mode: detail. pii phone or identifier means masked unless an authorized request sets unmask.
detail_columns[].keystringyes
detail_columns[].typeuuid · string · boolean · timestamp · integeryes
detail_columns[].piiphone · identifier | nullyes
detail_columns[].descriptionstringyes
limitsobjectyes
limits.max_window_daysintegeryes
limits.max_groupsintegeryes
limits.max_dimensionsintegeryes
limits.max_filter_valuesintegeryes
limits.max_detail_rowsintegeryes
json
{
  "fact": "call_attempts",
  "version": 1,
  "dimensions": [
    {
      "key": "campaign_id",
      "type": "uuid",
      "pii": null,
      "description": "Campaign the attempt belongs to."
    },
    {
      "key": "disposition",
      "type": "string",
      "pii": null,
      "description": "Disposition of the last settling correction; in_flight while it has none."
    },
    {
      "key": "day",
      "type": "date",
      "pii": null,
      "description": "Local calendar day of started_at in the request zone."
    }
  ],
  "metrics": [
    {
      "key": "attempts",
      "unit": "count",
      "description": "Attempts the platform actually placed."
    },
    {
      "key": "contact_rate_bp",
      "unit": "basis_points",
      "description": "Contacts over attempts in basis points; null when there are no attempts."
    }
  ],
  "detail_columns": [
    {
      "key": "attempt_id",
      "type": "uuid",
      "pii": null,
      "description": "The attempt."
    },
    {
      "key": "to_number",
      "type": "string",
      "pii": "phone",
      "description": "Dialed number. Masked (***0184) unless an authorized request sets unmask."
    }
  ],
  "limits": {
    "max_window_days": 31,
    "max_groups": 1000,
    "max_dimensions": 4,
    "max_filter_values": 100,
    "max_detail_rows": 1000
  }
}

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

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

Run one aggregate, or one page of attempts, of the report builder ​

http
POST /v1/reports/run

Runs ONE aggregate over the closed catalog (GET /v1/report-catalog) for the caller's tenant (tenant scope: a dd_ key or a dashboard login). The tenant is the principal's: a tenant_id in the body is 422 unknown_field, like every other field outside from, to, dimensions, metrics, filters and zone.

from and to are mandatory RFC 3339 timestamps; the window is [from, to) on started_at and at most 31 days (400 window_too_large; 400 too when to is not after from). Up to 4 dimensions (none gives a single totals row) and at least one metric. filters narrows by the dimensions that are filterable, each with a non-empty list of at most 100 values; agent_id matches an attempt in which ANY of its rows names the agent. zone (default UTC) is an IANA Area/Location name and decides the calendar of the day, week and month dimensions.

A request that would answer more than 1000 groups is refused whole (422 too_many_groups), never cut silently. The read runs under a statement timeout (503 report_timeout). Over the per-principal brake the answer is 429 rate_limited with Retry-After. With the tenant's report_builder_enabled flag off the answer is 503 report_builder_disabled and nothing is read.

contact_rate_bp is contacts over attempts in basis points, rounded half up, and null (never 0) when there are no attempts. The answer is JSON unless the query asks ?format=csv (an Accept header alone is not enough): the CSV has one header row of the column keys, carries X-Artifact-SHA256 and neutralises spreadsheet formulas.

Detail mode (core#1987). mode: detail answers one row per placed attempt instead of a group, taking columns (from detail_columns of the catalog), filters, unmask, limit (default and maximum 1000) and after (the next_cursor of the previous page, a keyset over (started_at, id), oldest first; a malformed one is 400 invalid_cursor). dimensions, metrics and zone are aggregate-only and columns, unmask, limit and after are detail-only: the wrong mode's key is 422 field_not_for_mode. The window and the brake are the aggregate's.

PII columns (pii: phone or identifier) are masked by default from the stored *_last4 columns, WITHOUT decrypting. unmask: true returns them whole and is allowed for a dd_ key or a ddu_ login with the account_admin role; a member gets 403 role_forbidden (never a silently masked answer) and a dds_ support session 403 forbidden (its allowlist is GET-only). unmask without a PII column in columns is 422 unmask_without_pii_column. Every unmasked page writes ONE row to the append-only evidence table in the SAME transaction as the read (actor, the definition, the columns, the unmasked columns, the row count; never the values); if that row cannot be written, nothing is returned. With ?format=csv the page carries an export_cursor column on every row (the last one is the after of the next page) and X-Artifact-SHA256; an unmasked number comes out as '+1312... behind the formula guard.

Instead of dimensions, metrics and filters the body may carry definition_id: a saved definition of the tenant (/v1/report-definitions). Both at once is 422 definition_conflict; a malformed id is 422 invalid_definition_id; an unknown id or another tenant's is 404. The saved keys are checked against the CURRENT catalog on every run: one the catalog no longer publishes is 422 catalog_changed naming it, and nothing is answered, not even a partial result. from, to and zone are always the run's own. A run by definition_id is always an aggregate: mode: detail with it is 422 field_not_for_mode.

Parameters

NameInTypeRequiredDescription
formatquerycsvnocsv answers a CSV artifact; anything else answers JSON.

Request body (JSON, required)

FieldTypeRequiredDescription
definition_idstring (uuid)noA saved definition of the tenant (/v1/report-definitions); its keys are re-checked against the current catalog (422 catalog_changed).
modeaggregate · detailnoaggregate requires metrics; detail requires columns.
columnsarray of stringnoDetail mode only. Keys from detail_columns of the catalog.
unmaskbooleannoDetail mode only. Returns the PII columns whole; dd_ or account_admin only (403 role_forbidden otherwise) and audited.
limitintegernoDetail mode only. Rows per page; above the maximum it is clamped.
afterstringnoDetail mode only. The next_cursor of the previous page.
fromstring (date-time)yesInclusive lower bound on started_at.
tostring (date-time)yesExclusive upper bound; at most 31 days after from.
dimensionsarray of stringnoKeys from the catalog; none answers one totals row.
metricsarray of stringnoKeys from the catalog.
filtersobjectnoDimension key to a non-empty list (at most 100) of values; only filterable dimensions.
zonestringnoUTC or an IANA Area/Location name; the calendar of day, week and month.
json
{
  "from": "2026-10-01T00:00:00Z",
  "to": "2026-10-08T00:00:00Z",
  "dimensions": [
    "campaign_id",
    "disposition"
  ],
  "metrics": [
    "attempts",
    "contacts",
    "contact_rate_bp"
  ],
  "filters": {
    "source": [
      "platform"
    ]
  },
  "zone": "UTC"
}

Responses

200 ​

The aggregate, or one page of the detail.

FieldTypeRequiredDescription
fact"call_attempts"yes
catalog_versionintegeryes
modedetailnoPresent (and detail) only on a detail page.
fromstring (date-time)yes
tostring (date-time)yes
zonestringnoAggregate only.
unmaskedarray of stringnoDetail only. The PII columns returned whole (empty when masked).
next_cursorstring | nullnoDetail only. The after of the next page; null on the last.
columnsarray of objectyes
columns[].keystringyes
columns[].kinddimension · metricnoAggregate only.
columns[].typestringyes
columns[].piistring | nullnoDetail only.
row_countintegeryes
rowsarray of objectyesOne object per group, keyed by the column keys. A metric that is undefined (contact_rate_bp with no attempts) is null.
json
{
  "fact": "call_attempts",
  "catalog_version": 1,
  "from": "2026-10-01T00:00:00Z",
  "to": "2026-10-08T00:00:00Z",
  "zone": "UTC",
  "columns": [
    {
      "key": "disposition",
      "kind": "dimension",
      "type": "string"
    },
    {
      "key": "attempts",
      "kind": "metric",
      "type": "count"
    },
    {
      "key": "contact_rate_bp",
      "kind": "metric",
      "type": "basis_points"
    }
  ],
  "row_count": 2,
  "rows": [
    {
      "disposition": "answered_human",
      "attempts": 40,
      "contact_rate_bp": 10000
    },
    {
      "disposition": "no_answer",
      "attempts": 60,
      "contact_rate_bp": 0
    }
  ]
}

400 ​

The body is not a JSON object, from/to are missing or not RFC 3339, to is not after from, the window is wider than 31 days (window_too_large), or after is not a valid cursor (invalid_cursor).

json
{
  "error": {
    "code": "window_too_large",
    "message": "the window may not exceed 31 days"
  }
}

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 ​

role_forbidden: unmask: true from a member. A dds_ support session is refused (forbidden) by its GET-only allowlist. Nothing was read and no evidence row exists.

json
{
  "error": {
    "code": "role_forbidden",
    "message": "unmask takes a dd_ key or a dashboard login with the account_admin role"
  }
}

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 ​

Something outside the closed vocabulary or a list bound: unknown_field, unknown_dimension, unknown_metric, unknown_column, unknown_filter, invalid_dimensions, invalid_metrics, invalid_columns, invalid_filters, invalid_zone, invalid_mode, invalid_limit, invalid_unmask, unmask_without_pii_column, field_not_for_mode, or too_many_groups when the answer would exceed 1000 groups. With definition_id: definition_conflict, invalid_definition_id, or catalog_changed (the saved definition names a key the catalog no longer publishes; the message names it).

json
{
  "error": {
    "code": "unknown_metric",
    "message": "unknown metric(s): revenue; published: attempts, contacts"
  }
}

429 — ApiKeyRateLimited ​

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

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

503 ​

The builder is off for this tenant (report_builder_disabled), or the read exceeded its statement timeout (report_timeout).

json
{
  "error": {
    "code": "report_builder_disabled",
    "message": "the report builder is not enabled for this tenant"
  }
}

The tenant's saved report definitions ​

http
GET /v1/report-definitions

Every saved definition of the caller's tenant (tenant scope: a dd_ key or a dashboard login), by name. A definition is a QUERY over the closed catalog (dimensions, metrics, filters), never its result: the window and the zone are parameters of each POST /v1/reports/run. Not gated by the tenant's report_builder_enabled flag: only running is.

Responses

200 ​

The definitions.

FieldTypeRequiredDescription
report_definitionsarray of ReportDefinitionyes
report_definitions[].idstring (uuid)yes
report_definitions[].namestringyes
report_definitions[].fact"call_attempts"yes
report_definitions[].catalog_versionintegeryesThe catalog version the definition was last saved against.
report_definitions[].dimensionsarray of stringyes
report_definitions[].metricsarray of stringyes
report_definitions[].filtersobjectyes
report_definitions[].created_bystring (uuid) | nullyesThe user who saved it; null when a dd_ key did.
report_definitions[].created_atstring (date-time)yes
report_definitions[].updated_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"
  }
}

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

Save a report definition ​

http
POST /v1/report-definitions

Validates the body against the closed catalog exactly like an inline POST /v1/reports/run: a key the catalog does not publish is 422 (unknown_dimension, unknown_metric, unknown_filter, ...) and the message names it. The tenant and the author are the principal's: a tenant_id or created_by in the body is 422 unknown_field. created_by is the user of a dashboard login and null for a dd_ key. name is unique per tenant (409 name_taken). fact and catalog_version are stamped by the server.

Request body (JSON, required)

FieldTypeRequiredDescription
namestringyesUnique within the tenant; trimmed.
dimensionsarray of stringnoKeys from the catalog.
metricsarray of stringyesKeys from the catalog.
filtersobjectnoDimension key to a non-empty list (at most 100) of values; only filterable dimensions.
json
{
  "name": "Contactabilidad por campaña",
  "dimensions": [
    "campaign_id"
  ],
  "metrics": [
    "attempts",
    "contacts",
    "contact_rate_bp"
  ],
  "filters": {
    "source": [
      "platform"
    ]
  }
}

Responses

201 ​

The saved definition.

FieldTypeRequiredDescription
report_definitionobject (ReportDefinition)yes
report_definition.idstring (uuid)yes
report_definition.namestringyes
report_definition.fact"call_attempts"yes
report_definition.catalog_versionintegeryesThe catalog version the definition was last saved against.
report_definition.dimensionsarray of stringyes
report_definition.metricsarray of stringyes
report_definition.filtersobjectyes
report_definition.created_bystring (uuid) | nullyesThe user who saved it; null when a dd_ key did.
report_definition.created_atstring (date-time)yes
report_definition.updated_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"
  }
}

409 ​

Another definition of this tenant already has the name (name_taken).

json
{
  "error": {
    "code": "name_taken",
    "message": "a report definition with this name already exists for this tenant"
  }
}

422 — Unprocessable ​

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

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

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

One saved report definition ​

http
GET /v1/report-definitions/{id}

Parameters

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

Responses

200 ​

The definition.

FieldTypeRequiredDescription
report_definitionobject (ReportDefinition)yes
report_definition.idstring (uuid)yes
report_definition.namestringyes
report_definition.fact"call_attempts"yes
report_definition.catalog_versionintegeryesThe catalog version the definition was last saved against.
report_definition.dimensionsarray of stringyes
report_definition.metricsarray of stringyes
report_definition.filtersobjectyes
report_definition.created_bystring (uuid) | nullyesThe user who saved it; null when a dd_ key did.
report_definition.created_atstring (date-time)yes
report_definition.updated_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"
  }
}

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

Replace a report definition ​

http
PUT /v1/report-definitions/{id}

Full replacement of name, dimensions, metrics and filters, validated like the creation; catalog_version is refreshed to the current catalog. id, created_by and created_at never change.

Parameters

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

Request body (JSON, required)

FieldTypeRequiredDescription
namestringyesUnique within the tenant; trimmed.
dimensionsarray of stringnoKeys from the catalog.
metricsarray of stringyesKeys from the catalog.
filtersobjectnoDimension key to a non-empty list (at most 100) of values; only filterable dimensions.

Responses

200 ​

The replaced definition.

FieldTypeRequiredDescription
report_definitionobject (ReportDefinition)yes
report_definition.idstring (uuid)yes
report_definition.namestringyes
report_definition.fact"call_attempts"yes
report_definition.catalog_versionintegeryesThe catalog version the definition was last saved against.
report_definition.dimensionsarray of stringyes
report_definition.metricsarray of stringyes
report_definition.filtersobjectyes
report_definition.created_bystring (uuid) | nullyesThe user who saved it; null when a dd_ key did.
report_definition.created_atstring (date-time)yes
report_definition.updated_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"
  }
}

409 ​

Another definition of this tenant already has the name (name_taken).

json
{
  "error": {
    "code": "name_taken",
    "message": "a report definition with this name already exists for this tenant"
  }
}

422 — Unprocessable ​

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

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

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

Delete a report definition ​

http
DELETE /v1/report-definitions/{id}

Parameters

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

Responses

204 ​

Deleted.

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

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

Export attempt history (Reg F) ​

http
GET /v1/reports/attempts

Keyset-paginated CSV export of the attempt ledger for the CMS round-trip (MT-REP-05 FASE 2). One row per attempt carrying its FINAL disposition: the original dial collapsed with every correction appended to it, the most recently written row wins. call_id is always the ORIGINAL attempt id, so a correction landing between two exports does not re-identify the attempt. source (platform | cms_import) is on every row so the CMS can tell what it declared from what the platform dialled.

Columns: debt_key, attempt_at (UTC RFC 3339), channel (voice), source, outcome, agent_id (empty when none), call_id, export_cursor, disclosure_status, disclosure_script_match. disclosure_status is what the agent DECLARED about the collection notice (attested | declared_not_delivered | unattested | not_applicable | undecidable, see CallAttemptDisclosure), derived by join like the rest of the row — a declaration, never "verified". disclosure_script_match (core#1691, ADR #219) is matched | other_version | unscripted | not_applicable, as CallAttemptDisclosure.script_match.

from and to are mandatory and MUST be at most 31 days apart (400 otherwise). Derived from the ledger on every call — nothing is materialised. Rows are ordered by attempt_at descending, then by call_id; resume with ?after=<export_cursor of the last row>. A malformed cursor is 400, never a silent restart from the first page. format=csv (or Accept: text/csv) is required: 400 without it.

campaign_id (uuid) and disposition narrow the export like they narrow GET /v1/call_attempts; a malformed campaign_id is 400, never ignored. The columns do not change. An export_cursor is only valid with the same filters it was produced under: resume a filtered export with the same campaign_id/disposition, and never carry its cursor into an unfiltered one.

PII (core#2018, ADR #240). The export is redacted by default: a debt_key that carries a payment card number, an SSN or a bank account number comes out masked (****-****-****-1234, ***-**-1234, ****1234), keeping at most the last four digits. A CMS that reconciles by the original key asks for include_pii=true, which takes a dd_ key or a dashboard login with the account_admin role (403 role_forbidden otherwise, never a silently redacted file) and is audited.

Parameters

NameInTypeRequiredDescription
include_piiquerybooleannotrue returns the file WITHOUT PII redaction. Requires a dd_ key or an account_admin login: 403 role_forbidden for anyone else. Absent or false ⇒ redacted. Anything that is not a boolean ⇒ 400.
fromquerystring (date-time)yesRFC 3339 lower bound on attempt_at (inclusive).
toquerystring (date-time)yesRFC 3339 upper bound on attempt_at (inclusive); at most 31 days after from.
campaign_idquerystring (uuid)no
dispositionquerystringnoExact match against the attempt's FINAL disposition, the value the CSV prints in outcome (a superseded earlier disposition does not match). Same vocabulary and the same 400s as DispositionFilter on GET /v1/call_attempts: empty, repeated or array form ⇒ 400; an unknown code ⇒ 200 with the header row only.
limitqueryintegernoPage size (rows per response); values above the maximum are capped.
afterquerystringnoOpaque keyset cursor: the export_cursor of the last row of the previous page. Malformed ⇒ 400.
formatquerycsvnocsv for the downloadable artifact (equivalent: Accept: text/csv).

Responses

200 ​

CSV artifact

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

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

Violations-Prevented compliance artifact ​

http
GET /v1/reports/violations-prevented

Packaged compliance report over the append-only gate_blocks evidence. by_campaign sorted by blocked count (desc); samples are the ≤3 most recent evidence rows PER gate, always masked (last-4 only — the DB CHECK refuses unmasked phones at write time). ?format=csv / Accept: text/csv renders the downloadable CSV (Content-Disposition: attachment) with X-Artifact-SHA256 for chain of custody.

Unit of every count in this report — totals.unit, plus the unit / unit_description rows of the CSV preamble — is deduplicated_gate_block_days: one deduplicated evidence row per unit, NOT one blocked call. The report counts TWO classes of row and they dedup on DIFFERENT keys: a campaign refusal counts once per (tenant, debt, gate, campaign, UTC day); an ad-hoc refusal (POST /v1/sms, no debt and no campaign) counts once per (tenant, gate, masked last-4, UTC day), which additionally collapses two different numbers sharing their last four digits (ADR #8; migration 20260728000002). The headline number therefore UNDER-counts blocked attempts.

Audit filters (campaign_id, debt_id, phone) narrow the WHOLE report — totals, by_campaign, policy_versions and samples — and are echoed in filters (and as filter_* rows of the CSV preamble) so a filtered artifact cannot pass for the tenant-wide one. With debt_id or phone set, samples holds up to 25 rows per gate instead of 3. There is no agent filter: a gate refuses a dial BEFORE any agent exists, and gate_blocks has no agent column.

Parameters

NameInTypeRequiredDescription
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
campaign_idquerystring (uuid)no
debt_idquerystring (uuid)no
phonequerystringnoE.164 number (+ percent-encoded as %2B); anything else is a 400. The table only keeps the masked form, so the filter matches on the LAST FOUR digits: two numbers sharing them are indistinguishable. The number itself is never echoed back (filters.phone_last4 is).
formatquerycsvnocsv for the downloadable artifact (equivalent: Accept: text/csv).

Responses

200 ​

The report (JSON) or the CSV artifact.

FieldTypeRequiredDescription
report"violations_prevented"no
tenant_idstring (uuid)no
generated_atstring (date-time)no
periodobjectno
period.fromstring (date-time)no
period.tostring (date-time)no
filtersobjectnoThe audit filters applied (null = not applied).
filters.campaign_idstring (uuid) | nullno
filters.debt_idstring (uuid) | nullno
filters.phone_last4string | nullno
totalsobjectno
totals.totalintegerno
totals.unit"deduplicated_gate_block_days"noThe unit of total, by_gate and every by_campaign count.
totals.unit_descriptionstringnoHuman-readable gloss of unit, served so the customer-facing artifact states what its headline number means: one deduplicated evidence row per unit, NOT one blocked call. A campaign refusal counts once per (tenant, debt, gate, campaign, UTC day); an ad-hoc refusal (POST /v1/sms, no debt and no campaign) counts once per (tenant, gate, masked last-4, UTC day), which additionally collapses two different numbers sharing their last four digits (ADR #8; migration 20260728000002).
totals.by_gateobjectno
by_campaignarray of objectnoSorted by blocked count (desc); same unit as totals.unit.
by_campaign[].campaign_idstring (uuid)no
by_campaign[].campaign_namestringno
by_campaign[].totalintegerno
by_campaign[].by_gateobjectno
policy_versionsarray of stringno
samplesarray of objectno≤3 most recent evidence rows PER gate (≤25 when debt_id or phone is filtered), always masked (last-4 only).
samples[].occurred_atstring (date-time)no
samples[].gatetcpa_no_consent · quiet_hours · regf_7in7 · regf_post_contact · state_limit · dnc_listed · consent_revoked · cease_and_desist · number_reassigned · tenant_policy · artificial_voice_no_consentnoThe compliance engine's reason-code vocabulary.
samples[].rule_idstringno
samples[].campaign_idstring (uuid) | nullno
samples[].debt_idstring (uuid)no
samples[].contact_phone_maskedstringno
samples[].policy_versionstringno
json
{
  "report": "violations_prevented",
  "tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
  "generated_at": "2026-07-05T10:00:00Z",
  "period": {
    "from": "2026-06-05T00:00:00Z",
    "to": "2026-07-05T00:00:00Z"
  },
  "totals": {
    "total": 5,
    "unit": "deduplicated_gate_block_days",
    "unit_description": "one deduplicated evidence row per unit, NOT one blocked call: a campaign refusal counts once per (tenant, debt, gate, campaign, UTC day); an ad-hoc refusal (POST /v1/sms, no debt and no campaign) counts once per (tenant, gate, masked last-4, UTC day), which additionally collapses two different numbers sharing their last four digits (ADR #8; migration 20260728000002)",
    "by_gate": {
      "quiet_hours": 4,
      "dnc_listed": 1
    }
  },
  "by_campaign": [
    {
      "campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
      "campaign_name": "postman-golden",
      "total": 5,
      "by_gate": {
        "quiet_hours": 4,
        "dnc_listed": 1
      }
    }
  ],
  "policy_versions": [
    "usa-cell.v1"
  ],
  "samples": [
    {
      "occurred_at": "2026-07-01T02:10:00Z",
      "gate": "quiet_hours",
      "rule_id": "usa-cell.quiet-hours",
      "campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
      "debt_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
      "contact_phone_masked": "***1001",
      "policy_version": "usa-cell.v1"
    }
  ]
}

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

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