Appearance
Reports
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.
Packaged compliance artifacts.
Endpoints
| Method | Path | Summary |
|---|---|---|
GET | /v1/reports/cdr | CDR as canonical-protojson dialer.v1.CallDetailRecord |
GET | /v1/report-catalog | Report builder catalog (dimensions, metrics, limits) |
POST | /v1/reports/run | Run one aggregate, or one page of attempts, of the report builder |
GET | /v1/report-definitions | The tenant's saved report definitions |
POST | /v1/report-definitions | Save 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/attempts | Export attempt history (Reg F) |
GET | /v1/reports/violations-prevented | Violations-Prevented compliance artifact |
CDR as canonical-protojson dialer.v1.CallDetailRecord
http
GET /v1/reports/cdrThe 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
| 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 records.
| Field | Type | Required | Description |
|---|---|---|---|
callDetailRecords | array of CallDetailRecord | no | |
callDetailRecords[].callId | string | no | |
callDetailRecords[].tenantId | string | no | |
callDetailRecords[].debtId | string | no | |
callDetailRecords[].consumerId | string | no | |
callDetailRecords[].campaignId | string | no | |
callDetailRecords[].direction | DIRECTION_UNSPECIFIED · DIRECTION_OUTBOUND · DIRECTION_INBOUND · DIRECTION_INTERNAL | no | |
callDetailRecords[].fromNumber | string | no | |
callDetailRecords[].toNumber | string | no | |
callDetailRecords[].startedAt | string (date-time) | no | |
callDetailRecords[].answeredAt | string (date-time) | no | |
callDetailRecords[].endedAt | string (date-time) | no | |
callDetailRecords[].durationSeconds | integer (uint32) | no | |
callDetailRecords[].billsec | integer (uint32) | no | |
callDetailRecords[].disposition | DISPOSITION_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_TRANSFERRED | no | |
callDetailRecords[].hangupCause | string | no | |
callDetailRecords[].sipResponseCode | integer (uint32) | no | |
callDetailRecords[].recordingUri | string | no | |
callDetailRecords[].aiUsed | boolean | no | |
callDetailRecords[].mediaPath | MEDIA_PATH_UNSPECIFIED · MEDIA_PATH_RTPENGINE_KERNEL · MEDIA_PATH_FREESWITCH | no | |
callDetailRecords[].carrier | string | no | |
callDetailRecords[].carrierTrunkId | string | no | |
callDetailRecords[].attestation | ATTESTATION_UNSPECIFIED · ATTESTATION_A · ATTESTATION_B · ATTESTATION_C · ATTESTATION_NONE | no | |
callDetailRecords[].costWeight | number (double) | no | |
callDetailRecords[].cell | string | no | |
callDetailRecords[].fsNode | string | no | |
callDetailRecords[].sipEdgeNode | string | no | |
callDetailRecords[].agentId | string | no | |
callDetailRecords[].complianceDecisionId | string | no | |
callDetailRecords[].recordVersion | integer (uint32) | no | |
callDetailRecords[].labels | object | no | disposition_code carries a REST disposition the Disposition enum does not list. |
callDetailRecords[].originationSource | ORIGINATION_SOURCE_UNSPECIFIED · ORIGINATION_SOURCE_CAMPAIGN_AUTO · ORIGINATION_SOURCE_AGENT_PREVIEW · ORIGINATION_SOURCE_AGENT_MANUAL · ORIGINATION_SOURCE_API_CTI · ORIGINATION_SOURCE_AI · ORIGINATION_SOURCE_WEB_REQUEST | no | |
callDetailRecords[].transferredToCallId | string | no | |
nextCursor | string | no | Opaque 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-catalogThe 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.
| Field | Type | Required | Description |
|---|---|---|---|
fact | "call_attempts" | yes | |
version | integer | yes | Bumps when a published definition changes. |
dimensions | array of object | yes | |
dimensions[].key | string | yes | |
dimensions[].type | uuid · string · boolean · date | yes | |
dimensions[].pii | string | null | yes | null on every published dimension: none carries personal data. |
dimensions[].description | string | yes | |
metrics | array of object | yes | |
metrics[].key | string | yes | |
metrics[].unit | count · seconds · basis_points | yes | |
metrics[].description | string | yes | |
detail_columns | array of object | yes | The vocabulary of mode: detail. pii phone or identifier means masked unless an authorized request sets unmask. |
detail_columns[].key | string | yes | |
detail_columns[].type | uuid · string · boolean · timestamp · integer | yes | |
detail_columns[].pii | phone · identifier | null | yes | |
detail_columns[].description | string | yes | |
limits | object | yes | |
limits.max_window_days | integer | yes | |
limits.max_groups | integer | yes | |
limits.max_dimensions | integer | yes | |
limits.max_filter_values | integer | yes | |
limits.max_detail_rows | integer | yes |
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/runRuns 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
format | query | csv | no | csv answers a CSV artifact; anything else answers JSON. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
definition_id | string (uuid) | no | A saved definition of the tenant (/v1/report-definitions); its keys are re-checked against the current catalog (422 catalog_changed). |
mode | aggregate · detail | no | aggregate requires metrics; detail requires columns. |
columns | array of string | no | Detail mode only. Keys from detail_columns of the catalog. |
unmask | boolean | no | Detail mode only. Returns the PII columns whole; dd_ or account_admin only (403 role_forbidden otherwise) and audited. |
limit | integer | no | Detail mode only. Rows per page; above the maximum it is clamped. |
after | string | no | Detail mode only. The next_cursor of the previous page. |
from | string (date-time) | yes | Inclusive lower bound on started_at. |
to | string (date-time) | yes | Exclusive upper bound; at most 31 days after from. |
dimensions | array of string | no | Keys from the catalog; none answers one totals row. |
metrics | array of string | no | Keys from the catalog. |
filters | object | no | Dimension key to a non-empty list (at most 100) of values; only filterable dimensions. |
zone | string | no | UTC 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.
| Field | Type | Required | Description |
|---|---|---|---|
fact | "call_attempts" | yes | |
catalog_version | integer | yes | |
mode | detail | no | Present (and detail) only on a detail page. |
from | string (date-time) | yes | |
to | string (date-time) | yes | |
zone | string | no | Aggregate only. |
unmasked | array of string | no | Detail only. The PII columns returned whole (empty when masked). |
next_cursor | string | null | no | Detail only. The after of the next page; null on the last. |
columns | array of object | yes | |
columns[].key | string | yes | |
columns[].kind | dimension · metric | no | Aggregate only. |
columns[].type | string | yes | |
columns[].pii | string | null | no | Detail only. |
row_count | integer | yes | |
rows | array of object | yes | One 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-definitionsEvery 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.
| Field | Type | Required | Description |
|---|---|---|---|
report_definitions | array of ReportDefinition | yes | |
report_definitions[].id | string (uuid) | yes | |
report_definitions[].name | string | yes | |
report_definitions[].fact | "call_attempts" | yes | |
report_definitions[].catalog_version | integer | yes | The catalog version the definition was last saved against. |
report_definitions[].dimensions | array of string | yes | |
report_definitions[].metrics | array of string | yes | |
report_definitions[].filters | object | yes | |
report_definitions[].created_by | string (uuid) | null | yes | The user who saved it; null when a dd_ key did. |
report_definitions[].created_at | string (date-time) | yes | |
report_definitions[].updated_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"
}
}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-definitionsValidates 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)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Unique within the tenant; trimmed. |
dimensions | array of string | no | Keys from the catalog. |
metrics | array of string | yes | Keys from the catalog. |
filters | object | no | Dimension 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.
| Field | Type | Required | Description |
|---|---|---|---|
report_definition | object (ReportDefinition) | yes | |
report_definition.id | string (uuid) | yes | |
report_definition.name | string | yes | |
report_definition.fact | "call_attempts" | yes | |
report_definition.catalog_version | integer | yes | The catalog version the definition was last saved against. |
report_definition.dimensions | array of string | yes | |
report_definition.metrics | array of string | yes | |
report_definition.filters | object | yes | |
report_definition.created_by | string (uuid) | null | yes | The user who saved it; null when a dd_ key did. |
report_definition.created_at | string (date-time) | yes | |
report_definition.updated_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"
}
}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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The definition.
| Field | Type | Required | Description |
|---|---|---|---|
report_definition | object (ReportDefinition) | yes | |
report_definition.id | string (uuid) | yes | |
report_definition.name | string | yes | |
report_definition.fact | "call_attempts" | yes | |
report_definition.catalog_version | integer | yes | The catalog version the definition was last saved against. |
report_definition.dimensions | array of string | yes | |
report_definition.metrics | array of string | yes | |
report_definition.filters | object | yes | |
report_definition.created_by | string (uuid) | null | yes | The user who saved it; null when a dd_ key did. |
report_definition.created_at | string (date-time) | yes | |
report_definition.updated_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"
}
}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
| 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 |
|---|---|---|---|
name | string | yes | Unique within the tenant; trimmed. |
dimensions | array of string | no | Keys from the catalog. |
metrics | array of string | yes | Keys from the catalog. |
filters | object | no | Dimension key to a non-empty list (at most 100) of values; only filterable dimensions. |
Responses
200
The replaced definition.
| Field | Type | Required | Description |
|---|---|---|---|
report_definition | object (ReportDefinition) | yes | |
report_definition.id | string (uuid) | yes | |
report_definition.name | string | yes | |
report_definition.fact | "call_attempts" | yes | |
report_definition.catalog_version | integer | yes | The catalog version the definition was last saved against. |
report_definition.dimensions | array of string | yes | |
report_definition.metrics | array of string | yes | |
report_definition.filters | object | yes | |
report_definition.created_by | string (uuid) | null | yes | The user who saved it; null when a dd_ key did. |
report_definition.created_at | string (date-time) | yes | |
report_definition.updated_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"
}
}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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource 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/attemptsKeyset-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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
include_pii | query | boolean | no | true 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. |
from | query | string (date-time) | yes | RFC 3339 lower bound on attempt_at (inclusive). |
to | query | string (date-time) | yes | RFC 3339 upper bound on attempt_at (inclusive); at most 31 days after from. |
campaign_id | query | string (uuid) | no | |
disposition | query | string | no | Exact 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. |
limit | query | integer | no | Page size (rows per response); values above the maximum are capped. |
after | query | string | no | Opaque keyset cursor: the export_cursor of the last row of the previous page. Malformed ⇒ 400. |
format | query | csv | no | csv 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-preventedPackaged 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
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. |
campaign_id | query | string (uuid) | no | |
debt_id | query | string (uuid) | no | |
phone | query | string | no | E.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). |
format | query | csv | no | csv for the downloadable artifact (equivalent: Accept: text/csv). |
Responses
200
The report (JSON) or the CSV artifact.
| Field | Type | Required | Description |
|---|---|---|---|
report | "violations_prevented" | no | |
tenant_id | string (uuid) | no | |
generated_at | string (date-time) | no | |
period | object | no | |
period.from | string (date-time) | no | |
period.to | string (date-time) | no | |
filters | object | no | The audit filters applied (null = not applied). |
filters.campaign_id | string (uuid) | null | no | |
filters.debt_id | string (uuid) | null | no | |
filters.phone_last4 | string | null | no | |
totals | object | no | |
totals.total | integer | no | |
totals.unit | "deduplicated_gate_block_days" | no | The unit of total, by_gate and every by_campaign count. |
totals.unit_description | string | no | Human-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_gate | object | no | |
by_campaign | array of object | no | Sorted by blocked count (desc); same unit as totals.unit. |
by_campaign[].campaign_id | string (uuid) | no | |
by_campaign[].campaign_name | string | no | |
by_campaign[].total | integer | no | |
by_campaign[].by_gate | object | no | |
policy_versions | array of string | no | |
samples | array of object | no | ≤3 most recent evidence rows PER gate (≤25 when debt_id or phone is filtered), always masked (last-4 only). |
samples[].occurred_at | string (date-time) | no | |
samples[].gate | tcpa_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_consent | no | The compliance engine's reason-code vocabulary. |
samples[].rule_id | string | no | |
samples[].campaign_id | string (uuid) | null | no | |
samples[].debt_id | string (uuid) | no | |
samples[].contact_phone_masked | string | no | |
samples[].policy_version | string | no |
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"
}
}