Appearance
SMS
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.
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/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 (core#1777, 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 (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"
}
}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 (core#1527, 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 (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"
}
}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 (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"
}
}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 (core#1777, 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 (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"
}
}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 (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"
}
}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 (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}One-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; core#2329). 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 (ADR #181). Neither has a per-tenant or per-campaign switch; rewording the body is the only way through.
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 (core#2415), 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 (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}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 (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"
}
}SMS inbox - one row per consumer/line thread
http
GET /v1/sms/threadsThe inbox read-model (core#1775, 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, core#1776); 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 (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}One 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 (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"
}
}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 (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}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"
}
}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 (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}