Skip to content

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.

HTTPerror.codeMeaning
400bad_requestMalformed JSON, bad query params, wrong field types.
400invalidA field violates a domain rule (validation error).
401unauthorizedMissing, unknown or revoked API key — see Authentication.
403forbiddenAccount suspended.
404not_foundUnknown, malformed or another account's id — existence is never leaked.
409conflictUniqueness or state-machine violation (duplicate name, invalid lifecycle transition, busy seat).
415unsupported_media_typeBody-carrying request without a JSON content type.
422unprocessableShape is fine, semantics are not (a broken domain rule).
422compliance_blockedThe compliance engine refused the operation — see below.
422sms_optout_notice_missing / sms_content_prohibitedThe SMS body was refused before any gate ran — see SMS body checks.
429throttled / sms_spend_cap_exceededRate and spend brakes — see Rate limits & caps.
500internalOur fault. Retry with backoff and quote the X-Request-ID to support.
502provider_error / dial_error / sbc_errorAn 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, or tenant_policy. number_reassigned is 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 ​

SurfaceHow 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-dialingBlocks 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.codeThe body…What fixes it
sms_optout_notice_missingnames 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_prohibitednames 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 match debt 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_id and no evidence row: the engine never evaluated the send, so these refusals do not appear in GET /v1/stats/blocked or 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. POST and PATCH /v1/campaigns refuse an sms_body that breaks either rule with 400 invalid and 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 its sms_body is 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_idWhy the call was refusedWhat fixes it
tcpa.artificial_voice.optout.v1A TTS, IVR or survey campaign has no optout_digit.Set the key on the campaign — see artificial-voice campaigns.
egress.destination_not_allowed.v1The 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.v1The 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; every decision_id you 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.

HTTPerror.codeMeaning
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.
400bad_requestThe body carries a password: the invited person chooses it.
403role_forbiddenThe account has an active account_admin and the caller is a member login or a dd_ key: inviting takes an account_admin login.
409conflictThe address is already a user of your account, active or disabled.
429rate_limitedYour 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.

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