Appearance
SMS
Generated from
DialerDigital/corecommitbbb8e373d900— specopenapi/dialer-v1.yamlv0.151.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
What SMS is today
SMS sending through your own provider is available at the API level today; the complete collections messaging product is on the roadmap.
Gated one-off SMS send + unified ledger.
Endpoints
| Method | Path | Summary |
|---|---|---|
GET | /v1/tenant/sms-business-hours | The tenant's business hours for SMS |
PUT | /v1/tenant/sms-business-hours | Set the tenant's business hours for SMS |
DELETE | /v1/tenant/sms-business-hours | Clear the tenant's business hours for SMS |
GET | /v1/tenant/sms-auto-reply | The tenant's out-of-hours SMS auto-reply |
PUT | /v1/tenant/sms-auto-reply | Save a new version of the SMS auto-reply |
GET | /v1/sandbox/records | The test CDR of the sandbox |
GET | /v1/sms/messages | SMS ledger query |
POST | /v1/sms/messages | One-off gated SMS send |
GET | /v1/sms/messages/{id} | Collapsed single-message view |
GET | /v1/sms/threads | SMS inbox - one row per consumer/line thread |
GET | /v1/sms/threads/{id} | One SMS inbox thread with the debts of its consumer number |
POST | /v1/sms/threads/{id}/assign | Assign an SMS inbox thread to a user, or release it |
POST | /v1/sms/threads/{id}/read | Mark an SMS inbox thread read |
The tenant's business hours for SMS
http
GET /v1/tenant/sms-business-hoursThe hours an inbound text is read against to decide whether it arrived OUTSIDE them (ADR 228), or null when the tenant has none. With no hours the automatic reply never goes out. Readable by a dd_ key and by any dashboard login.
Responses
200
The stored hours, or null.
| Field | Type | Required | Description |
|---|---|---|---|
sms_business_hours | TenantCallingWindow | null | 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 (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. 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 (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 stored hours could not be read (never answered as "no hours").
Set the tenant's business hours for SMS
http
PUT /v1/tenant/sms-business-hoursReplaces the hours; the body has the shape of the tenant calling window and is validated by the same code. Tenant management only: a dd_ key or an account_admin login; a member login is 403 role_forbidden. A malformed body is 422 with the reason and the previous hours stay. The write is audited. Saving the hours does not switch the auto-reply on.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
timezone | string | yes | An IANA zone name. A zone that does not resolve is 422. |
weekly | object (CampaignWeeklyWindow) | yes | Per-weekday windows keyed mon…sun; at least one day. A day that is not a key has no window and is closed. |
weekly.mon | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
weekly.mon.start | string | yes | |
weekly.mon.end | string | yes | |
weekly.tue | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
weekly.tue.start | string | yes | |
weekly.tue.end | string | yes | |
weekly.wed | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
weekly.wed.start | string | yes | |
weekly.wed.end | string | yes | |
weekly.thu | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
weekly.thu.start | string | yes | |
weekly.thu.end | string | yes | |
weekly.fri | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
weekly.fri.start | string | yes | |
weekly.fri.end | string | yes | |
weekly.sat | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
weekly.sat.start | string | yes | |
weekly.sat.end | string | yes | |
weekly.sun | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
weekly.sun.start | string | yes | |
weekly.sun.end | string | yes |
json
{
"timezone": "America/Chicago",
"weekly": {
"mon": {
"start": "09:00",
"end": "17:00"
},
"tue": {
"start": "09:00",
"end": "17:00"
}
}
}Responses
200
Stored (normalised).
| Field | Type | Required | Description |
|---|---|---|---|
sms_business_hours | object (TenantCallingWindow) | yes | The tenant's own calling hours (R19 key tenant_calling_window). It only NARROWS: R18 (per destination) and the campaign's window still apply. The hours are read in timezone (the tenant's, NOT the destination's). |
sms_business_hours.timezone | string | yes | An IANA zone name. A zone that does not resolve is 422. |
sms_business_hours.weekly | object (CampaignWeeklyWindow) | yes | Per-weekday windows keyed mon…sun; at least one day. A day that is not a key has no window and is closed. |
sms_business_hours.weekly.mon | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
sms_business_hours.weekly.mon.start | string | yes | |
sms_business_hours.weekly.mon.end | string | yes | |
sms_business_hours.weekly.tue | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
sms_business_hours.weekly.tue.start | string | yes | |
sms_business_hours.weekly.tue.end | string | yes | |
sms_business_hours.weekly.wed | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
sms_business_hours.weekly.wed.start | string | yes | |
sms_business_hours.weekly.wed.end | string | yes | |
sms_business_hours.weekly.thu | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
sms_business_hours.weekly.thu.start | string | yes | |
sms_business_hours.weekly.thu.end | string | yes | |
sms_business_hours.weekly.fri | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
sms_business_hours.weekly.fri.start | string | yes | |
sms_business_hours.weekly.fri.end | string | yes | |
sms_business_hours.weekly.sat | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
sms_business_hours.weekly.sat.start | string | yes | |
sms_business_hours.weekly.sat.end | string | yes | |
sms_business_hours.weekly.sun | object (CampaignDayWindow) | no | One weekday's window, within the day: start strictly before end. HH:MM is accepted and stored as HH:MM:SS. No offset: the window is read in the zone the campaign reads the hour in. |
sms_business_hours.weekly.sun.start | string | yes | |
sms_business_hours.weekly.sun.end | string | 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
role_forbidden: a dashboard login without the account_admin role; forbidden: a widget token or a suspended tenant.
422
Malformed hours; the message names the reason. Nothing is stored.
429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (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"
}
}Clear the tenant's business hours for SMS
http
DELETE /v1/tenant/sms-business-hoursBack to no hours (idempotent), which turns the auto-reply off. Same roles as PUT.
Responses
200
Cleared.
| Field | Type | Required | Description |
|---|---|---|---|
sms_business_hours | null | 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 (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. 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 (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 hours could not be cleared.
The tenant's out-of-hours SMS auto-reply
http
GET /v1/tenant/sms-auto-replyThe latest saved version of the reply sent to an inbound text received outside the tenant's business hours (ADR 228), or null when none was ever saved. Reading it says nothing about whether it is active: that needs the operator flag sms_auto_reply_enabled, business hours, and every gate of the SMS send path. Readable by a dd_ key and by any dashboard login.
Responses
200
The latest version, or null.
| Field | Type | Required | Description |
|---|---|---|---|
sms_auto_reply | SmsAutoReply | null | 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 (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. 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 (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 stored reply could not be read (never answered as "none").
Save a new version of the SMS auto-reply
http
PUT /v1/tenant/sms-auto-replyAppends a version (earlier ones are kept; the latest is in force). Tenant management only: a dd_ key or an account_admin login; a member login is 403 role_forbidden. The body is validated like the SMS send path validates it: it must carry an opt-out notice that the inbound keyword handling honours and must not contain a prohibited term; a rejection is 422 naming the field and nothing is stored. A tenant on templates-only cannot store free text (422 sms_templates_only).
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
body | string | yes | The text sent. It must name an opt-out word that inbound keyword handling honours and must not contain a prohibited term. |
language | string | yes | A language tag, en or es-MX style. |
cooldown_seconds | integer | no | At most one auto-reply per thread in this many seconds (1 hour to 30 days). |
json
{
"body": "We are closed right now. Reply STOP to opt out.",
"language": "en",
"cooldown_seconds": 86400
}Responses
200
The version just saved.
| Field | Type | Required | Description |
|---|---|---|---|
sms_auto_reply | object (SmsAutoReply) | yes | The version of the SMS auto-reply currently in force. |
sms_auto_reply.body | string | yes | |
sms_auto_reply.language | string | yes | |
sms_auto_reply.cooldown_seconds | integer | yes | |
sms_auto_reply.set_by | string,null (uuid) | yes | The dashboard user that saved it; null for an API key. |
sms_auto_reply.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
role_forbidden: a dashboard login without the account_admin role; forbidden: a widget token or a suspended tenant.
422
unprocessable: the message names the field and the reason; sms_templates_only: the tenant accepts templates only.
429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (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"
}
}The test CDR of the sandbox
http
GET /v1/sandbox/recordsThe simulated calls and SMS of the tenant's dd_test_ keys, newest first, APART from the real CDR: /v1/reports/cdr and /v1/sms/messages never include them, and they are neither metered nor charged. With a dd_test_ key, POST /v1/dial/manual (number) and POST /v1/sms/messages (to, body) answer a deterministic simulated result chosen by the destination, and reach no carrier or provider:
| number | call status | SMS status |
|---|---|---|
+13125550100 | completed | delivered |
+13125550101 | busy | undelivered |
+13125550102 | no_answer | sent |
+13125550103 | failed | failed |
Any other destination is 422 unknown_test_number. A simulated call also queues call.answered (answered only) and call.ended to the tenant's subscribed endpoints, signed with X-DD-Signature like a real one, with livemode: false and environment: "test" in the envelope. SMS has no webhook (no outbound-SMS event exists).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
kind | query | call · sms | no | |
limit | query | integer | no |
Responses
200
The test records.
| Field | Type | Required | Description |
|---|---|---|---|
environment | test | yes | |
records | array of object | yes | |
records[].id | string (uuid) | yes | |
records[].environment | test | yes | |
records[].kind | call · sms | yes | |
records[].to_number | string | yes | |
records[].outcome | answered · busy · no_answer · failed | yes | |
records[].status | string | yes | |
records[].client_ref | string,null | no | |
records[].body_length | integer,null | no | |
records[].started_at | string (date-time) | yes | |
records[].answered_at | string,null (date-time) | no | |
records[].ended_at | string,null (date-time) | no |
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 (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"
}
}SMS ledger query
http
GET /v1/sms/messagesLedger ROWS — originals AND status transitions (corrects_id distinguishes) — newest first, keyset-paginated like /v1/call_attempts. Bodies are 40-char previews (body_preview) in lists.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
direction | query | outbound · inbound | no | |
status | query | queued · sent · delivered · undelivered · failed · received | no | |
campaign_id | query | string (uuid) | no | |
debt_id | query | string (uuid) | no | |
thread_id | query | string (uuid) | no | Only the rows of one inbox thread (thread_id of /v1/sms/threads). A malformed id is 400; an id of another tenant matches nothing. |
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 ledger rows.
| Field | Type | Required | Description |
|---|---|---|---|
sms_messages | array of SmsMessage | yes | |
sms_messages[].id | string (uuid) | no | |
sms_messages[].direction | outbound · inbound | no | |
sms_messages[].status | queued · sent · delivered · undelivered · failed · received | no | |
sms_messages[].from_e164 | string | null | no | |
sms_messages[].to_e164 | string | no | |
sms_messages[].body_preview | string | null | no | |
sms_messages[].segments | integer | null | no | |
sms_messages[].client_ref | string | null | no | |
sms_messages[].provider_id | string (uuid) | null | no | |
sms_messages[].provider_message_id | string | null | no | |
sms_messages[].campaign_id | string (uuid) | null | no | |
sms_messages[].debt_id | string (uuid) | null | no | |
sms_messages[].thread_id | string (uuid) | null | no | Inbox thread of the row; null on rows written before threads existed. |
sms_messages[].occurred_at | string (date-time) | no | |
sms_messages[].corrects_id | string (uuid) | null | no | |
sms_messages[].recorded_at | string (date-time) | no | |
next_cursor | string | null | 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"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (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-off gated SMS send
http
POST /v1/sms/messagesRoutes ONE message through THE choke point (Dialer.Sms.Sender, the same Originator as campaigns): spend cap → compliance engine (Consent → DNC → QuietHours → SmsFrequency, fail-closed) → evidence-first queued ledger row → provider adapter → sms_segment debit at provider ACCEPT. The API can no more bypass a gate than a campaign can.
client_ref is the caller idempotency key — retries MUST reuse it; a replayed client_ref answers 202 with duplicate: true and the provider is NOT contacted again. When timezone is absent quiet-hours resolves the destination's NPA — unresolvable numbers are blocked fail-closed. Provider resolution walks sede → empresa-wide → deployment config; no provider → 422 sms_provider_unconfigured. A request with no from whose resolved provider has no default_from_e164 → 422 from_required: nothing is queued and the provider is not contacted (the conversation line cannot be decided, so the send fails closed). The sender (from, or the provider's default_from_e164) must be E.164 and an ACTIVE number of the tenant: otherwise 422 sms_from_invalid / 422 sms_from_not_owned — nothing is queued and the provider is not contacted (per-tenant knob sms_from_ownership_enforced, default true). A row that resolution selects but whose base_url no longer answers as an https endpoint on port 443 at a public address → 422 sms_provider_base_url_rejected: the SSRF guard re-resolves the host at SEND time (not just at registration), so a name re-pointed at loopback, RFC1918, CGNAT or link-local afterwards is caught here and the send fails closed instead of falling back to another endpoint. A prepaid tenant whose send has no sell rate (no SMS segment rate for the region) → 422 no_rate: refused, but not a payment refusal — topping up changes nothing, and retrying unchanged will refuse again.
Before any of that — before the plan gate, the brakes and the engine — the body itself is checked, with ZERO provider calls and ZERO ledger rows on a refusal. A body that names no honored opt-out keyword → 422 sms_optout_notice_missing. A body that names a carrier-prohibited term (SHAFT: sex, hate, alcohol, firearm(s), tobacco; debt relief, consolidation, forgiveness, credit repair — whole words, case-insensitive) → 422 sms_content_prohibited. Neither has a per-tenant or per-campaign switch; rewording the body is the only way through.
A sender that is a US local (10DLC) number with no approved 10DLC campaign → 422 sms_10dlc_campaign_required, with ZERO provider calls and ZERO ledger rows. There is no per-request switch: assign the number to an approved campaign (POST /v1/messaging/campaigns/{id}/numbers). Toll-free senders and regions other than us are not gated by 10DLC.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
to | string | yes | Destination, E.164. |
body | string | yes | Message text (include the STOP opt-out notice; no carrier-prohibited term, see above). |
from | string | no | Sender E.164 / short code. Optional: when absent core uses the default_from_e164 of the resolved provider and sends THROUGH that line, so the outbound and the consumer's reply share one thread. Neither present → 422 from_required. Whichever applies must be E.164 and an ACTIVE number of the tenant (422 sms_from_invalid / 422 sms_from_not_owned). With the tenant key did_ownership_proof in enforce, a customer number whose ownership was never verified is 422 did_unverified. |
client_ref | string | no | Idempotency key (default a fresh uuid). |
debt_id | string (uuid) | no | Attribution (with campaign_id, also lands the durable gate_blocks row on a block). |
campaign_id | string (uuid) | no | |
consumer_ref | string | no | |
timezone | string | no | IANA timezone for quiet-hours; NPA fallback when absent. |
json
{
"to": "+19995550123",
"body": "postman probe. Responda STOP para no recibir mas mensajes.",
"client_ref": "postman-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
}Responses
202
Accepted (queued → sent, billed at ACCEPT) — or replayed client_ref (duplicate: true, provider NOT contacted again).
| Field | Type | Required | Description |
|---|---|---|---|
sms_message | SmsMessageView | null | yes | |
duplicate | boolean | yes |
json
{
"sms_message": {
"id": "0e9f8a7b-6c5d-4e3f-2a1b-0c9d8e7f6a5b",
"direction": "outbound",
"status": "sent",
"from_e164": "+13125550199",
"to_e164": "+19995550123",
"body": "postman probe. Responda STOP para no recibir mas mensajes.",
"segments": 1,
"client_ref": "postman-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"provider_id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
"provider_message_id": "mock-0001",
"campaign_id": null,
"debt_id": null,
"compliance_snapshot": {
"decision_id": "2c1d0e9f-8a7b-6c5d-4e3f-2a1b0c9d8e7f"
},
"occurred_at": "2026-07-05T10:00:00Z",
"timestamps": {
"queued": "2026-07-05T10:00:00Z",
"sent": "2026-07-05T10:00:01Z"
},
"transitions": []
},
"duplicate": false
}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 (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. 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"
}
}422 — ComplianceBlocked
The compliance engine refused the operation (fail-closed). gate names the refusing gate; decision_id cites the audit-chain decision — attach it to any dispute. ZERO provider calls, ZERO ledger rows happened. Do NOT retry unchanged: the block is evidence, not a transient error. (This 422 is distinct from the generic unprocessable 422 and from sms_provider_unconfigured, which share the status code but differ in code.)
json
{
"error": {
"code": "compliance_blocked",
"message": "the compliance engine refused this send (dnc_listed)",
"gate": "dnc_listed",
"decision_id": "2c1d0e9f-8a7b-6c5d-4e3f-2a1b0c9d8e7f"
}
}429 — SmsRateLimited
Money/rate brakes, both retryable — but with different semantics. codesms_spend_cap_exceeded: month-to-date SMS spend is at the tenant cap; retry only after a cap raise or the month rollover. code throttled: rate brake; retry later with backoff. No Retry-After header is set: the send throttle does not know when capacity returns, and neither does the spend cap (a cap raise or the month rollover). Over the per-key quota this operation answers rate_limited as in ApiKeyRateLimited.
spend_cap
json
{
"error": {
"code": "sms_spend_cap_exceeded",
"message": "month-to-date SMS spend is at the tenant cap"
}
}throttled
json
{
"error": {
"code": "throttled",
"message": "SMS send throttled; retry later"
}
}502 — SmsProviderError
The SMS provider rejected the message: the send was queued (evidence-first) and is evidenced as a failed transition — never billed. sms_message_id points at the ledger row.
json
{
"error": {
"code": "provider_error",
"message": "the SMS provider rejected the message (:timeout)",
"sms_message_id": "0e9f8a7b-6c5d-4e3f-2a1b-0c9d8e7f6a5b"
}
}Collapsed single-message view
http
GET /v1/sms/messages/{id}The original's identity + CURRENT status, per-status timestamps, frozen compliance_snapshot, full body and the ordered transitions rows.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The collapsed view.
| Field | Type | Required | Description |
|---|---|---|---|
sms_message | object (SmsMessageView) | yes | The COLLAPSED single-message view — current status, per-status timestamps, frozen compliance snapshot, FULL body, ordered transitions. |
sms_message.id | string (uuid) | no | |
sms_message.direction | outbound · inbound | no | |
sms_message.status | queued · sent · delivered · undelivered · failed · received | no | |
sms_message.from_e164 | string | null | no | |
sms_message.to_e164 | string | no | |
sms_message.body | string | null | no | |
sms_message.segments | integer | null | no | |
sms_message.client_ref | string | null | no | |
sms_message.provider_id | string (uuid) | null | no | |
sms_message.provider_message_id | string | null | no | |
sms_message.campaign_id | string (uuid) | null | no | |
sms_message.debt_id | string (uuid) | null | no | |
sms_message.thread_id | string (uuid) | null | no | Inbox thread of the message; null on messages written before threads existed. |
sms_message.compliance_snapshot | object | null | no | |
sms_message.occurred_at | string (date-time) | no | |
sms_message.timestamps | object | no | Per-status timestamps (e.g. queued, sent, delivered). |
sms_message.transitions | array of SmsMessage | no | |
sms_message.transitions[].id | string (uuid) | no | |
sms_message.transitions[].direction | outbound · inbound | no | |
sms_message.transitions[].status | queued · sent · delivered · undelivered · failed · received | no | |
sms_message.transitions[].from_e164 | string | null | no | |
sms_message.transitions[].to_e164 | string | no | |
sms_message.transitions[].body_preview | string | null | no | |
sms_message.transitions[].segments | integer | null | no | |
sms_message.transitions[].client_ref | string | null | no | |
sms_message.transitions[].provider_id | string (uuid) | null | no | |
sms_message.transitions[].provider_message_id | string | null | no | |
sms_message.transitions[].campaign_id | string (uuid) | null | no | |
sms_message.transitions[].debt_id | string (uuid) | null | no | |
sms_message.transitions[].thread_id | string (uuid) | null | no | Inbox thread of the row; null on rows written before threads existed. |
sms_message.transitions[].occurred_at | string (date-time) | no | |
sms_message.transitions[].corrects_id | string (uuid) | null | no | |
sms_message.transitions[].recorded_at | string (date-time) | no |
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 (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"
}
}SMS inbox - one row per consumer/line thread
http
GET /v1/sms/threadsThe inbox read-model (ADR 228): one row per thread - the ordered pair (consumer number, our line) - with activity in the window, newest first by the thread's last original message. DERIVED on read from the append-only ledger: no table, counter or materialisation. A delivery transition never moves a thread; only an original message does.
from/to default to the trailing 31 days; a window wider than 31 days, or with from after to, is 400. The last message shown is the last one INSIDE the window.
unread=true keeps only threads with an inbound message in the window that is newer than the thread's last read event (POST /v1/sms/threads/{id}/read); with no read event every inbound counts. assigned_to is the user the thread was last assigned to (POST /v1/sms/threads/{id}/assign), or null.
Messages written before threads existed carry no thread and never appear here. A response never carries the thread's identity digest: thread_id is the only handle. The list shows a 40-char preview of the last body.
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. |
unread | query | boolean | no | true keeps only threads with an inbound message in the window. |
limit | query | integer | no | Page size (default 50, max 200). |
cursor | query | string | no | Opaque keyset cursor from the previous page's next_cursor. |
Responses
200
One page of threads.
| Field | Type | Required | Description |
|---|---|---|---|
sms_threads | array of SmsThread | yes | |
sms_threads[].thread_id | string (uuid) | yes | Opaque identity of the thread; the only handle (no digest is ever returned). |
sms_threads[].tenant_id | string (uuid) | yes | |
sms_threads[].consumer_number | string | yes | The consumer's E.164 number, read off the last message. |
sms_threads[].line_number | string | yes | Our line's E.164 number. |
sms_threads[].last_message_id | string (uuid) | yes | |
sms_threads[].last_direction | outbound · inbound | yes | |
sms_threads[].last_body_preview | string | null | yes | |
sms_threads[].last_message_at | string (date-time) | yes | |
sms_threads[].unread | boolean | yes | true if the window holds an inbound message of the thread newer than the thread's last read event. |
sms_threads[].assigned_to | string (uuid) | null | yes | User the thread was last assigned to (the user_id of its last assigned/unassigned event); null if never assigned or released. |
next_cursor | string | null | yes |
json
{
"sms_threads": [
{
"thread_id": "5b1c0d2e-3f4a-4b5c-8d6e-7f8091a2b3c4",
"tenant_id": "9c8d7e6f-5a4b-4c3d-2e1f-0a9b8c7d6e5f",
"consumer_number": "+19995550123",
"line_number": "+13125550199",
"last_message_id": "0e9f8a7b-6c5d-4e3f-2a1b-0c9d8e7f6a5b",
"last_direction": "inbound",
"last_body_preview": "Gracias, pago el viernes. Me pueden llamar d…",
"last_message_at": "2026-10-07T10:00:00Z",
"unread": true,
"assigned_to": null
}
],
"next_cursor": null
}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 (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 SMS inbox thread with the debts of its consumer number
http
GET /v1/sms/threads/{id}The list row of /v1/sms/threads for one thread, plus debt_ids: the tenant's debts whose contact carries the consumer's number (matched by blind index inside the tenant), at most 200, ordered by id. An empty debt_ids means no debt has that number - it is never "no filter". Unlike the list there is no window: the last message is the thread's last original, so an old thread still opens; unread uses the trailing 31 days (after the last read event). Full bodies are served only by /v1/sms/messages/{id}; list the thread's rows with /v1/sms/messages?thread_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 thread.
| Field | Type | Required | Description |
|---|---|---|---|
sms_thread | any | yes | An inbox thread plus the debts that carry the consumer's number. |
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 (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"
}
}Assign an SMS inbox thread to a user, or release it
http
POST /v1/sms/threads/{id}/assignWrites ONE append-only event (assigned, or unassigned when user_id is null) signed by the calling login; the state is never overwritten. assigned_to in /v1/sms/threads is the user_id of the thread's last assigned/unassigned event. Assigning again, even to the same user, writes another event.
Needs a dashboard login (ddu_): a dd_ key or an agent token has no person to sign and is 403 role_forbidden. A member may assign the thread only to itself and release only a thread it holds (403 role_forbidden otherwise); an account_admin may assign it to any ACTIVE user of the same tenant. A user of another tenant, an unknown user and a disabled user are all 422 invalid_assignee. The body must carry user_id (a UUID, or null to release); omitting it is 400.
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 |
|---|---|---|---|
user_id | string (uuid) | null | yes | User to assign the thread to; null releases it. |
Responses
201
The recorded event.
| Field | Type | Required | Description |
|---|---|---|---|
sms_thread_event | object (SmsThreadEvent) | yes | One append-only event of an inbox thread, as recorded. |
sms_thread_event.thread_id | string (uuid) | yes | |
sms_thread_event.kind | assigned · unassigned · read | yes | |
sms_thread_event.user_id | string (uuid) | null | yes | The assignee of an assigned event; null for unassigned and read. |
sms_thread_event.actor_user_id | string (uuid) | yes | The login that wrote the event. |
sms_thread_event.occurred_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 (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. 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"
}
}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 (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"
}
}Mark an SMS inbox thread read
http
POST /v1/sms/threads/{id}/readWrites ONE append-only read event signed by the calling login (no body). unread in /v1/sms/threads then counts only inbound messages that arrived after the thread's last read event. Any dashboard login of the tenant may do it; a dd_ key or an agent token is 403 role_forbidden.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
201
The recorded event.
| Field | Type | Required | Description |
|---|---|---|---|
sms_thread_event | object (SmsThreadEvent) | yes | One append-only event of an inbox thread, as recorded. |
sms_thread_event.thread_id | string (uuid) | yes | |
sms_thread_event.kind | assigned · unassigned · read | yes | |
sms_thread_event.user_id | string (uuid) | null | yes | The assignee of an assigned event; null for unassigned and read. |
sms_thread_event.actor_user_id | string (uuid) | yes | The login that wrote the event. |
sms_thread_event.occurred_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 (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. 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 (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"
}
}