Appearance
Errors & compliance
Versión en español: Errores y cumplimiento.
The error envelope
Every error, on every endpoint, has the same shape:
json
{
"error": {
"code": "conflict",
"message": "campaign cannot transition from completed to running"
}
}code is stable and machine-readable — branch on it. message is for humans and may be reworded at any time — never parse it.
| HTTP | error.code | Meaning |
|---|---|---|
400 | bad_request | Malformed JSON, bad query params, wrong field types. |
400 | invalid | A field violates a domain rule (validation error). |
401 | unauthorized | Missing, unknown or revoked API key — see Authentication. |
403 | forbidden | Account suspended. |
404 | not_found | Unknown, malformed or another account's id — existence is never leaked. |
409 | conflict | Uniqueness or state-machine violation (duplicate name, invalid lifecycle transition, busy seat). |
415 | unsupported_media_type | Body-carrying request without a JSON content type. |
422 | unprocessable | Shape is fine, semantics are not (a broken domain rule). |
422 | compliance_blocked | The compliance engine refused the operation — see below. |
422 | sms_optout_notice_missing / sms_content_prohibited | The SMS body was refused before any gate ran — see SMS body checks. |
429 | throttled / sms_spend_cap_exceeded | Rate and spend brakes — see Rate limits & caps. |
500 | internal | Our fault. Retry with backoff and quote the X-Request-ID to support. |
502 | provider_error / dial_error / sbc_error | An upstream (SMS provider, switch, SIP edge) rejected the operation. |
Endpoint-specific codes (e.g. sms_provider_unconfigured) are documented per response in the endpoint reference.
422 compliance_blocked — the platform that refuses to break the law
Dialer Digital has exactly one path to a consumer's phone: a gated engine that checks consent, do-not-call status, quiet hours, contact-frequency rules (Reg F 7-in-7 and post-contact), state-level limits and your own account policies — before any provider is contacted. Your API calls go through the same gates as our campaigns and our dashboard. There is no bypass parameter, and none can be added by mistake: the engine fails closed.
Reassigned numbers: refused, not looked up
Checking whether a number was reassigned needs a Reassigned Numbers Database provider, and the platform does not query one today. The engine does not treat the missing lookup as a pass: while no provider is configured, it refuses the dial or send, reported as tenant_policy. It does not report number_reassigned for this refusal, because no registry was asked.
When a gate refuses, you get:
json
{
"error": {
"code": "compliance_blocked",
"message": "the compliance engine refused this send (dnc_listed)",
"gate": "dnc_listed",
"decision_id": "2c1d0e9f-8a7b-6c5d-4e3f-2a1b0c9d8e7f"
}
}gate— which rule refused:tcpa_no_consent,quiet_hours,regf_7in7,regf_post_contact,state_limit,dnc_listed,consent_revoked,cease_and_desist,number_reassigned, ortenant_policy.number_reassignedis only reported when a reassigned-numbers registry was actually consulted — see the warning above.decision_id— a citation into the append-only audit chain. The decision that blocked the operation is durable evidence: if a consumer or regulator ever asks "did you try to contact this person?", the answer is a verifiable record, in your favor.
A block is evidence, not an error
Nothing happened — zero provider calls, zero charges. Do not retry unchanged: the input that was refused will be refused again, and hammering a gate only adds noise to your own audit trail. Fix the underlying state (obtain consent, wait out quiet hours, honor the revocation) or move on to the next account. This is the feature you are paying for — every block is a violation that never reached a consumer, packaged for your defense at GET /v1/reports/violations-prevented.
Where blocks show up
| Surface | How a block is reported |
|---|---|
One-off SMS (POST /v1/sms/messages) | 422 compliance_blocked with gate + decision_id. |
Manual/preview dial (POST /v1/campaigns/{id}/dial) | 200 with {"status": "blocked", "reason": "<gate>"} — a valid outcome the agent should see, not a server error. |
| Campaign auto-dialing | Blocks never reach the API caller; they are recorded and aggregated at GET /v1/stats/blocked. |
Blind transfer to an external number (POST /v1/calls/{call_id}/transfer) | 422 compliance_blocked; gate says which check refused. gate: "mid_call": the re-check over the consumer refused (consent revoked, cease-and-desist, quiet hours) — it carries reason_code and rule_id, and the automatic hang-up of the call is armed (auto_hangup_at, when present, says when). gate: "pre_dial": the new leg to the destination was refused — it carries reason_code, rule_id and, when the evidence row was written, decision_id; nothing was dialed and the consumer stays on the call. |
SMS body checks
Before the engine sees an SMS — before your plan, rate and spend brakes too — the platform reads the body itself. Two rules, both on the one-off send and on every SMS campaign:
error.code | The body… | What fixes it |
|---|---|---|
sms_optout_notice_missing | names no opt-out keyword the platform honors on inbound replies (STOP, ALTO, UNSUBSCRIBE, CANCEL, QUIT, END). Reg F, 12 CFR 1006.6(e). | Tell the consumer how to stop: "Reply STOP to opt out." |
sms_content_prohibited | names content US carriers sanction regardless of opt-in: SHAFT (sex, hate, alcohol, firearm, firearms, tobacco) or debt relief (debt relief, consolidation, forgiveness, credit repair). | Reword the message. |
json
{
"error": {
"code": "sms_content_prohibited",
"message": "body must not contain carrier-prohibited content (SHAFT: sex, hate, alcohol, firearms, tobacco; debt relief, consolidation, forgiveness, credit repair)"
}
}- Whole words, any case. "Essex" does not match
sex; "Debt-Relief" and "debt relief" both matchdebt relief. "debt" and "credit" on their own are ordinary collections vocabulary and are not on the list. - It blocks more than it has to, on purpose. A listed word is refused in every sense — "we hate to bother you" is refused. A carrier content violation is charged to the platform; rewording costs you a sentence.
- Nothing happened. Zero provider calls, zero ledger rows, zero charges. There is no
decision_idand no evidence row: the engine never evaluated the send, so these refusals do not appear inGET /v1/stats/blockedor the violations-prevented report. - No switch. No account, campaign or request parameter skips either check. Retrying the same body is refused again.
- Campaigns get the same answer earlier.
POSTandPATCH /v1/campaignsrefuse ansms_bodythat breaks either rule with400 invalidand the same message, so a campaign never starts with a body every send would refuse. A campaign saved before a rule existed stops sending, and accepts no change (not even a pause) until itssms_bodyis fixed.
Rollout
sms_content_prohibited is live since core v0.76.0 (core#1351, core ADR #181). sms_optout_notice_missing is older: it was already in core v0.75.0.
tenant_policy blocks and their rule_id
tenant_policy covers more than one rule — the reassigned-numbers refusal above is one of them. The rule_id on the decision (GET /v1/compliance/decisions/{id}, and the samples of GET /v1/reports/violations-prevented) says which one. Three of them:
rule_id | Why the call was refused | What fixes it |
|---|---|---|
tcpa.artificial_voice.optout.v1 | A TTS, IVR or survey campaign has no optout_digit. | Set the key on the campaign — see artificial-voice campaigns. |
egress.destination_not_allowed.v1 | The number is outside the destination allowlist of one of your BYOC trunks. | Add it to the list, or it is not meant to be called — see BYOC trunks: destination allowlist. |
egress.destination_allowlist_unavailable.v1 | The allowlist could not be read, so the call was refused rather than risked. | Retry later; if it persists, contact support. |
Turning blocks into paperwork
GET /v1/stats/blocked— by-gate counts and a daily series, computed from the append-only evidence table.GET /v1/reports/violations-prevented— the packaged report (JSON or CSV) you can hand to a client, auditor or court.GET /v1/debts/{id}/defense-packet— the sealed, per-account litigation bundle; everydecision_idyou ever received is citable against it.
404 and multi-tenancy
Cross-account ids, malformed UUIDs and genuinely unknown ids are all the same 404 not_found. This is enforced by row-level security in the database — the API cannot tell you whether someone else's resource exists, by design.
Inviting a user: one answer for a free address and a taken one
POST /v1/users invites a person to the dashboard (core v0.77.0, core ADR #182). It follows the same rule as the 404 above: it never tells you about another account.
| HTTP | error.code | Meaning |
|---|---|---|
202 | — | Invitation recorded: {"status": "invited", "message": "invitation recorded"}. The same body, byte for byte, whether the address is free or already a user of another account. |
400 | bad_request | The body carries a password: the invited person chooses it. |
403 | role_forbidden | The account has an active account_admin and the caller is a member login or a dd_ key: inviting takes an account_admin login. |
409 | conflict | The address is already a user of your account, active or disabled. |
429 | rate_limited | Your account sent too many invitations in a short time. Retry later. |
Redeeming the link (POST /v1/auth/password-reset/confirm) fails in one way only: an unknown, expired, used or replaced link, or one whose address became a user in the meantime, is the same 401 unauthorized. A password that is too short is 422 and leaves the link valid. The whole flow, and how to migrate an integration that still sends password, is in Dashboard users.