Appearance
Compliance
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.
Suppression data the tenant owns (its internal do-not-call list, uploaded as CSV into the same list the pre-dial gate reads) and the audit-chain citation: resolving a decision_id into its sealed link plus the integrity verdict over it.
Endpoints
| Method | Path | Summary |
|---|---|---|
POST | /v1/suppressions/import | Upload the tenant's own do-not-call list (CSV) |
GET | /v1/compliance/decisions/{decision_id} | Resolve and verify one decision_id in the audit chain |
GET | /v1/compliance/sources | State of the compliance sources (DNC lists, state registries, RND) |
GET | /v1/artificial-voice-disclosure | Fetch the tenant NON-AI artificial-voice disclosure |
PUT | /v1/artificial-voice-disclosure | Store the tenant NON-AI artificial-voice disclosure |
Upload the tenant's own do-not-call list (CSV)
http
POST /v1/suppressions/importLoads the tenant's INTERNAL suppression list from a text/csv body.
Rows join the internal_tenant DNC list that the PRE-DIAL compliance gate already consults on every call and every SMS — the same list SMS STOP writes. There is no second suppression path and nothing to enable: an accepted number is suppressed for the next attempt.
Format: one row per number, number[,reason]. A header row (number, phone, phone_number, telephone, e164, msisdn) is optional; blank lines and lines starting with # are skipped. Numbers are normalised the same way the regulatory dumps are: E.164 is kept, a bare 10-digit or 1+10-digit NANP number becomes +1XXXXXXXXXX.
Malformed rows are REPORTED, not fatal: refusing a whole file over one typo would leave the remaining consumers dialable, which is the expensive direction of the error. Rejected values come back MASKED — the line number is what identifies the row to fix.
Re-uploading a list is idempotent (recorded counts rows actually written, so an overlap reads as an overlap and not as new suppressions).
Limits: 1 000 000 bytes of body, 10 000 data rows, 200 characters of reason, and at most 100 entries in rejected (rejected_count always carries the true total).
Responses
200
Upload outcome. A 200 does NOT mean every row was accepted — rejected_count is authoritative.
| Field | Type | Required | Description |
|---|---|---|---|
received | integer | yes | Data rows the document offered (header/blank lines excluded). |
accepted | integer | yes | Rows that normalised to a distinct E.164 number. |
recorded | integer | yes | Rows actually written. Lower than accepted when the list overlaps suppressions already on file. |
rejected_count | integer | yes | |
rejected | array of object | yes | Up to 100 entries; see rejected_count for the total. |
rejected[].line | integer | yes | 1-based line number in the uploaded document. |
rejected[].value | string | yes | The offending value, masked to its last 4 characters. |
rejected[].reason | not_e164 · duplicate | yes |
json
{
"received": 3,
"accepted": 2,
"recorded": 2,
"rejected_count": 1,
"rejected": [
{
"line": 4,
"value": "***5551",
"reason": "not_e164"
}
]
}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"
}
}413
The CSV body exceeds 1 000 000 bytes.
json
{
"error": {
"code": "payload_too_large",
"message": "csv body exceeds 1000000 bytes"
}
}415
The request body is not text/csv.
json
{
"error": {
"code": "unsupported_media_type",
"message": "this endpoint takes a text/csv body"
}
}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"
}
}Resolve and verify one decision_id in the audit chain
http
GET /v1/compliance/decisions/{decision_id}A refused operation hands you a decision_id (422 compliance_blocked). This resolves that citation into its sealed link of the append-only compliance audit chain, together with the integrity verdict over it — so the claim is one you can check rather than one you have to accept.
decision.payload is the canonical JSON that was hashed, shipped verbatim: re-run sha256(prev_hash || canonical_json(payload)) yourself and compare against verification.hash. canonical_json sorts object keys lexicographically at every depth. All three hashes are lowercase hex sha256; genesis_hash is the fixed root of every tenant chain.
Three booleans, not one, because three different things can be wrong:
payload_hash_matches— the payload is consistent with the hash stored BESIDE IT, in the same row. Read that literally: it proves internal consistency, NOT authenticity. An adversary with database access can rewritepayloadandhashin one statement and turn this green.chains_to_previous— it still hangs off the link before it (or off the genesis, whenseqis 1). Deleting the PREVIOUS link leaves this one internally intact while the chain is broken.successor_chains_to_this— the NEXT link still carries this link's hash. A coherent rewrite cannot fake this without re-chaining everything that came after, so it is what turns the first check from "consistent" into "unaltered".
status is intact only when all three hold.
At the tip of the chain there is no successor, and there a coherent rewrite is undetectable from inside the chain. That case is not dressed up as intact: successor_chains_to_this is null, is_chain_tip is true and status is intact_unwitnessed. It becomes intact on its own as soon as the next decision is sealed.
Tamper detection is a 200 with status: "tampered", not an error: the endpoint answering truthfully about a damaged chain IS the feature.
Not every decision_id we hand out has a link, and that is the one sharp edge of this surface. The evidence table deduplicates refusals per day: a second refusal of the same debt (or, for ad-hoc sends, the same masked last-4) through the same gate on the same UTC day writes no new evidence row, and so seals no new link — while the 422 still returns a fresh decision_id. That citation resolves 404 here, and the 404 is deliberately byte-identical to the one for an id that never existed, so this route cannot be used to probe which ids are real. Concretely: a citation that does not resolve is NOT a denial that the refusal happened — that day's refusal is evidenced by the FIRST link for that debt (or masked number), gate and day.
Scope: this resolves only the calling tenant's own decisions, and it never carries the E.164 — the sealed payload holds the masked last-4 by construction. Credential-less third-party verification is not part of this surface.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
decision_id | path | string (uuid) | yes | The decision_id handed back by the refusal. |
Responses
200
The link and its integrity verdict.
| Field | Type | Required | Description |
|---|---|---|---|
decision | object | no | The sealed link, as stored. |
decision.decision_id | string (uuid) | no | |
decision.tenant_id | string (uuid) | no | |
decision.seq | integer | no | Position in THIS tenant's chain; 1 is the first link. |
decision.channel | voice · sms | no | |
decision.allowed | boolean | no | Whether the operation was permitted. false is the litigated case. |
decision.reason_code | string | no | |
decision.rule_id | string | no | |
decision.policy_version | string | no | |
decision.subject_table | string | no | Which evidence row this link seals (call_attempts, gate_blocks, sms_messages, tenant_posture_events, call_disclosure_events). Empty string when the decision left no evidence row — not an error. |
decision.subject_id | string,null (uuid) | no | Id of that evidence row; null when subject_table is empty. |
decision.debt_ref | string | no | Your STABLE account reference, not our row id. Empty when there is none. |
decision.campaign_id | string,null (uuid) | no | null for a decision outside any campaign (e.g. a one-off POST /v1/sms/messages), and for every posture-change link. |
decision.to_number_masked | string | no | Last-4 only (***1234). The E.164 is never sealed and never returned. |
decision.decided_at | string (date-time) | no | |
decision.sealed_at | string (date-time) | no | When the link was written — same transaction as the evidence row it seals. |
decision.payload | object | no | The canonical JSON that was hashed, verbatim. This is what makes the citation checkable without trusting us: re-run the recipe over it. |
verification | object | no | The integrity verdict over this ONE link. |
verification.status | intact · intact_unwitnessed · tampered | no | intact only when ALL THREE booleans below hold. intact_unwitnessed when the first two hold and there is no successor yet (the tip of the chain). tampered when any of the three fails. |
verification.payload_hash_matches | boolean | no | This link's payload hashes to the hash stored BESIDE IT. Internal consistency, not authenticity: a database-level adversary can rewrite both columns at once and turn this green. successor_chains_to_this is what rules that out. |
verification.chains_to_previous | boolean | no | Its prev_hash is the hash of link seq - 1 (or the genesis when seq is 1). false also when that predecessor is GONE — which is how a deletion becomes visible. |
verification.successor_chains_to_this | boolean,null | no | The link at seq + 1 still carries this link's hash as its prev_hash. null when there is no successor (this is the newest link — see is_chain_tip). A coherent rewrite of this link alone flips it to false. |
verification.is_chain_tip | boolean | no | true when no later link exists yet. At the tip nothing witnesses this link, so a coherent rewrite of it cannot be detected from inside the chain, and status says intact_unwitnessed rather than intact. |
verification.seq | integer | no | |
verification.hash | string | no | Lowercase hex sha256 of this link. |
verification.prev_hash | string | no | Lowercase hex sha256 this link chains back to. |
verification.genesis_hash | string | no | The fixed root of every tenant chain, published so the recipe is reproducible from scratch. It is a CONSTANT, and the value in the example above is the real one — not a placeholder. seq: 1 chains back to it. |
verification.algorithm | "sha256" | no | |
verification.recipe | string | no | The literal recipe to re-run. |
verification.canonical_json | string | no | How payload must be serialized before hashing. |
verification.verified_at | string (date-time) | no |
json
{
"decision": {
"decision_id": "8c5a1f3e-2b7d-4e91-a0c6-3f5b9d2e7a41",
"tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
"seq": 42,
"channel": "voice",
"allowed": false,
"reason_code": "quiet_hours",
"rule_id": "usa-cell.quiet-hours",
"policy_version": "usa-cell.v1",
"subject_table": "gate_blocks",
"subject_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"debt_ref": "ACCT-10293",
"campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
"to_number_masked": "***1001",
"decided_at": "2026-07-01T02:10:00.123456Z",
"sealed_at": "2026-07-01T02:10:00.128901Z",
"payload": {
"decision_id": "8c5a1f3e-2b7d-4e91-a0c6-3f5b9d2e7a41",
"gate": "pre_dial",
"reason_code": "quiet_hours"
}
},
"verification": {
"status": "intact",
"payload_hash_matches": true,
"chains_to_previous": true,
"successor_chains_to_this": true,
"is_chain_tip": false,
"seq": 42,
"hash": "9f2c4d7a1b8e35c06a4f9d2e7b1c3a58d6f0e4b29c7a13d5e8f06b2c4a9d7e13",
"prev_hash": "3a7e1c9d5b2f84061e7d3c5a9b0f2e4d8c6a1b3f5d7e9c0a2b4d6f8e1a3c5b70",
"genesis_hash": "cc0941d5e3377c3422517794a524f59be703c6a7e1a2bae2d596c34d784dddd7",
"algorithm": "sha256",
"recipe": "sha256(prev_hash || canonical_json(payload))",
"canonical_json": "JSON with object keys sorted lexicographically at every depth",
"verified_at": "2026-07-05T10:00:00.000000Z"
}
}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"
}
}404
No link resolves this id for the calling tenant. Byte-identical for every cause, by design (no existence oracle): the id never existed, it is malformed, it belongs to another tenant, or — the cause specific to this route — it was handed out by a refusal that deduplicated against an existing evidence row for that day and therefore sealed no link of its own. A 404 here is NOT a denial that the refusal happened.
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"
}
}State of the compliance sources (DNC lists, state registries, RND)
http
GET /v1/compliance/sourcesWhat this tenant's dials are scrubbed against, and how fresh it is, as one cheap read for a settings panel. It reads the premises only: no campaign, no lead and no gate is evaluated (GET /v1/campaigns/{id}/compliance-preflight carries the same premises inside a per-campaign dry-run over up to 10000 leads, and reports age in days only). Read-only.
DNC (dnc). The three global lists (federal, state, internal_global) and, under state_registries, every state registry the platform declares it scrubs, keyed by state code. The lists are global regulatory data, identical for every tenant of the cell. Each source carries the posture the gate acts on (status, age_days, max_age_days are the gate's own numbers, not a copy) plus the dates behind it: updated_at is when the SOURCE data was cut (not when the import ran), and expires_at is the first instant at which the gate treats the snapshot as stale and refuses to claim a scrub (updated_at + (max_age_days + 1) days: the gate compares whole days). source is the provenance string of the load (for example ftc-san:2026-07-28) and record_count the rows loaded. A list nobody imported is absent, with updated_at, expires_at, age_days and record_count null: the gate refuses over it. max_age_days is 31 for every list today (the FTC safe-harbor ceiling); a state registry reports its own.
Litigator (litigator). Core has no litigator list of its own: the platform's litigators are rows of the internal_global DNC list, mixed with honeypots, and nothing distinguishes them. available is therefore always false and covered_by names the list that holds them, so a client can omit the entry instead of showing a freshness that does not exist.
RND (rnd). The Reassigned Numbers Database. provider is what the cell declares (declared; stub, the development provider that answers clean without asking a registry; or no_verificable, none declared, and the gate refuses every dial). The answers are the tenant's, so last_collected_at is the last time a results file was collected for the calling tenant (null if none is recorded). valid_since is the cycle floor the gate applies (the last database update; answers collected at or before it no longer back the safe harbor) and database_updated_at the last update the platform observed, null while only the calendar rule applies. status compares the two: current (collected after valid_since), stale (collected before it) or never.
Responses
200
State of every compliance source for the calling tenant.
| Field | Type | Required | Description |
|---|---|---|---|
as_of | string (date-time) | yes | The instant every age and status was computed at. |
dnc | object | yes | |
dnc.federal | object (ComplianceDncSource) | yes | |
dnc.federal.status | provisioned · stale · absent | yes | stale and absent are refused by the gate: no scrub is claimed over them. |
dnc.federal.source | string | yes | Provenance string of the load (none when absent). |
dnc.federal.record_count | integer,null | yes | |
dnc.federal.updated_at | string,null (date-time) | yes | When the SOURCE data was cut; null when absent. |
dnc.federal.expires_at | string,null (date-time) | yes | First instant at which the gate treats the snapshot as stale; null when absent. |
dnc.federal.age_days | integer,null | yes | Whole days since updated_at (0 for a future date); null when absent. |
dnc.federal.max_age_days | integer | yes | Freshness ceiling for this source. |
dnc.state | object (ComplianceDncSource) | yes | |
dnc.state.status | provisioned · stale · absent | yes | stale and absent are refused by the gate: no scrub is claimed over them. |
dnc.state.source | string | yes | Provenance string of the load (none when absent). |
dnc.state.record_count | integer,null | yes | |
dnc.state.updated_at | string,null (date-time) | yes | When the SOURCE data was cut; null when absent. |
dnc.state.expires_at | string,null (date-time) | yes | First instant at which the gate treats the snapshot as stale; null when absent. |
dnc.state.age_days | integer,null | yes | Whole days since updated_at (0 for a future date); null when absent. |
dnc.state.max_age_days | integer | yes | Freshness ceiling for this source. |
dnc.state_registries | object | yes | Every declared per-state DNC registry, keyed by state code, each against its own ceiling. |
dnc.internal_global | object (ComplianceDncSource) | yes | |
dnc.internal_global.status | provisioned · stale · absent | yes | stale and absent are refused by the gate: no scrub is claimed over them. |
dnc.internal_global.source | string | yes | Provenance string of the load (none when absent). |
dnc.internal_global.record_count | integer,null | yes | |
dnc.internal_global.updated_at | string,null (date-time) | yes | When the SOURCE data was cut; null when absent. |
dnc.internal_global.expires_at | string,null (date-time) | yes | First instant at which the gate treats the snapshot as stale; null when absent. |
dnc.internal_global.age_days | integer,null | yes | Whole days since updated_at (0 for a future date); null when absent. |
dnc.internal_global.max_age_days | integer | yes | Freshness ceiling for this source. |
litigator | object | yes | |
litigator.available | false | yes | Core keeps no litigator list of its own. |
litigator.covered_by | string | yes | The DNC list whose rows include the platform's litigators. |
rnd | object | yes | |
rnd.provider | object | yes | |
rnd.provider.status | declared · stub · no_verificable | yes | |
rnd.provider.provider | string,null | yes | Name of the declared provider; null when none. |
rnd.status | current · stale · never | yes | The tenant's last collection against the cycle floor (valid_since). |
rnd.last_collected_at | string,null (date-time) | yes | Last time a results file was collected for the calling tenant. |
rnd.valid_since | string (date-time) | yes | Answers collected at or before this instant no longer back the safe harbor. |
rnd.database_updated_at | string,null (date-time) | yes | Last update of the administrator's database the platform observed; null while only the calendar rule applies. |
json
{
"as_of": "2026-10-15T09:30:00.000000Z",
"dnc": {
"federal": {
"status": "provisioned",
"source": "ftc-san:2026-10-01",
"record_count": 240000000,
"updated_at": "2026-10-01T00:00:00.000000Z",
"expires_at": "2026-11-02T00:00:00.000000Z",
"age_days": 14,
"max_age_days": 31
},
"state": {
"status": "provisioned",
"source": "states:2026-10-01",
"record_count": 1200000,
"updated_at": "2026-10-01T00:00:00.000000Z",
"expires_at": "2026-11-02T00:00:00.000000Z",
"age_days": 14,
"max_age_days": 31
},
"state_registries": {
"FL": {
"status": "provisioned",
"source": "state-fl:2026-10-01",
"record_count": 700000,
"updated_at": "2026-10-01T00:00:00.000000Z",
"expires_at": "2026-11-02T00:00:00.000000Z",
"age_days": 14,
"max_age_days": 31
},
"IN": {
"status": "stale",
"source": "state-in:2026-08-01",
"record_count": 90000,
"updated_at": "2026-08-01T00:00:00.000000Z",
"expires_at": "2026-09-02T00:00:00.000000Z",
"age_days": 75,
"max_age_days": 31
},
"MO": {
"status": "absent",
"source": "none",
"record_count": null,
"updated_at": null,
"expires_at": null,
"age_days": null,
"max_age_days": 31
}
},
"internal_global": {
"status": "provisioned",
"source": "platform",
"record_count": 1500,
"updated_at": "2026-10-15T00:00:00.000000Z",
"expires_at": "2026-11-16T00:00:00.000000Z",
"age_days": 0,
"max_age_days": 31
}
},
"litigator": {
"available": false,
"covered_by": "internal_global"
},
"rnd": {
"provider": {
"status": "declared",
"provider": "Dialer.Compliance.RND.Reader"
},
"status": "current",
"last_collected_at": "2026-10-12T14:02:11.000000Z",
"valid_since": "2026-09-17T12:00:00.000000Z",
"database_updated_at": null
}
}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"
}
}Fetch the tenant NON-AI artificial-voice disclosure
http
GET /v1/artificial-voice-disclosureThe stored disclosure for the NON-AI artificial-voice lane (TTS / IVR / survey). A tenant with nothing stored is 404 not_found — NOT a 200 with a null body: "no disclosure stored" is a real compliance state, and a client branching on the status code must not read it as configured.
Responses
200
The stored disclosure.
| Field | Type | Required | Description |
|---|---|---|---|
artificial_voice_disclosure | object (ArtificialVoiceDisclosure) | yes | |
artificial_voice_disclosure.text | string | yes | The stored disclosure text. |
artificial_voice_disclosure.source | api · import · migration | yes | Provenance — how the row got here. Server-set, never taken from the body. |
artificial_voice_disclosure.actor | string | yes | The principal that wrote it, narrowest-first: the acting agent id, else the dashboard user id when the caller is a ddu_ session, else tenant-api-key for a dd_ key that carries no finer identity. Server-set. |
artificial_voice_disclosure.valid | boolean | yes | Whether the stored text STILL passes the content check. Re-computed on every read, not stored: the marker vocabulary can gain a term, and a row written under the older one must not keep vouching for a text the gate would no longer accept. This is the same question the dialing path asks. It does NOT say whether the text carries the {optout_digit} marker, which a TTS / IVR / survey campaign ALSO needs to dial (core#1305). |
artificial_voice_disclosure.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"
}
}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"
}
}Store the tenant NON-AI artificial-voice disclosure
http
PUT /v1/artificial-voice-disclosureUpsert on the tenant (one row, last write wins). The TEXT is judged by the SAME content check the AI lane applies: it must clearly identify the voice as automated, in English or Spanish. A text that does not is 400 bad_request and NOTHING is stored — a gate cannot be handed a true the text has not earned.
Opt-out instructions (core#1305, 47 CFR §64.1200(b)(3)). Write the instruction in your own words, with the marker {optout_digit} where the key goes: Press {optout_digit} to stop these calls. Every TTS / IVR / survey campaign plays this disclosure first and replaces each marker with ITS OWN optout_digit, so the key the consumer hears is always the key that works. Never write the key itself. A text without the marker is still stored (it is a valid disclosure), but no TTS / IVR / survey campaign dials with it: the campaign's /stats reports prompt_unavailable_reason: optout_instructions_missing.
source and actor are provenance and are set by the SERVER (api plus the acting principal); sending either is 400 bad_request naming the key. Tenant-management scope (dd_ API key or dashboard login; ddw_ → 403).
Role rules (core#2438): a dd_ key or an account_admin login; a member login is 403 role_forbidden (per-tenant knob compliance_config_admin_only, default on). The GET of this resource is not affected.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
text | string | yes | Must clearly identify the voice as automated (EN or ES). Anything else is 400 bad_request and nothing is stored. The ceiling exists because the dialing path re-reads this row on every attempt. Include the opt-out instruction with the {optout_digit} marker where the key goes (exact spelling, lower case); each campaign replaces it with its own key. Without the marker no TTS / IVR / survey campaign dials (optout_instructions_missing, core#1305). The EFFECTIVE ceiling is 10000 BYTES (UTF-8), enforced by the server and by a database CHECK. JSON Schema maxLength counts CHARACTERS and has no byte-length keyword, so the maxLength: 10000 above is an UPPER bound, not the real limit: a multibyte text can be refused with 400 bad_request well before reaching 10000 characters. The machine-readable form of the real limit is x-maxLengthBytes. See ADR #74. |
json
{
"text": "Hello, this is an automated call from Acme Collections. To stop these calls, press {optout_digit}."
}Responses
200
The stored disclosure.
| Field | Type | Required | Description |
|---|---|---|---|
artificial_voice_disclosure | object (ArtificialVoiceDisclosure) | yes | |
artificial_voice_disclosure.text | string | yes | The stored disclosure text. |
artificial_voice_disclosure.source | api · import · migration | yes | Provenance — how the row got here. Server-set, never taken from the body. |
artificial_voice_disclosure.actor | string | yes | The principal that wrote it, narrowest-first: the acting agent id, else the dashboard user id when the caller is a ddu_ session, else tenant-api-key for a dd_ key that carries no finer identity. Server-set. |
artificial_voice_disclosure.valid | boolean | yes | Whether the stored text STILL passes the content check. Re-computed on every read, not stored: the marker vocabulary can gain a term, and a row written under the older one must not keep vouching for a text the gate would no longer accept. This is the same question the dialing path asks. It does NOT say whether the text carries the {optout_digit} marker, which a TTS / IVR / survey campaign ALSO needs to dial (core#1305). |
artificial_voice_disclosure.updated_at | string (date-time) | yes |
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"
}
}