Skip to content

SMS ​

Generated from DialerDigital/core commit bbb8e373d900 — spec openapi/dialer-v1.yaml v0.151.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.

Español

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 ​

MethodPathSummary
GET/v1/tenant/sms-business-hoursThe tenant's business hours for SMS
PUT/v1/tenant/sms-business-hoursSet the tenant's business hours for SMS
DELETE/v1/tenant/sms-business-hoursClear the tenant's business hours for SMS
GET/v1/tenant/sms-auto-replyThe tenant's out-of-hours SMS auto-reply
PUT/v1/tenant/sms-auto-replySave a new version of the SMS auto-reply
GET/v1/sandbox/recordsThe test CDR of the sandbox
GET/v1/sms/messagesSMS ledger query
POST/v1/sms/messagesOne-off gated SMS send
GET/v1/sms/messages/{id}Collapsed single-message view
GET/v1/sms/threadsSMS 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}/assignAssign an SMS inbox thread to a user, or release it
POST/v1/sms/threads/{id}/readMark an SMS inbox thread read

The tenant's business hours for SMS ​

http
GET /v1/tenant/sms-business-hours

The 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.

FieldTypeRequiredDescription
sms_business_hoursTenantCallingWindow | nullyes

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-hours

Replaces 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)

FieldTypeRequiredDescription
timezonestringyesAn IANA zone name. A zone that does not resolve is 422.
weeklyobject (CampaignWeeklyWindow)yesPer-weekday windows keyed mon…sun; at least one day. A day that is not a key has no window and is closed.
weekly.monobject (CampaignDayWindow)noOne 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.startstringyes
weekly.mon.endstringyes
weekly.tueobject (CampaignDayWindow)noOne 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.startstringyes
weekly.tue.endstringyes
weekly.wedobject (CampaignDayWindow)noOne 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.startstringyes
weekly.wed.endstringyes
weekly.thuobject (CampaignDayWindow)noOne 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.startstringyes
weekly.thu.endstringyes
weekly.friobject (CampaignDayWindow)noOne 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.startstringyes
weekly.fri.endstringyes
weekly.satobject (CampaignDayWindow)noOne 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.startstringyes
weekly.sat.endstringyes
weekly.sunobject (CampaignDayWindow)noOne 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.startstringyes
weekly.sun.endstringyes
json
{
  "timezone": "America/Chicago",
  "weekly": {
    "mon": {
      "start": "09:00",
      "end": "17:00"
    },
    "tue": {
      "start": "09:00",
      "end": "17:00"
    }
  }
}

Responses

200 ​

Stored (normalised).

FieldTypeRequiredDescription
sms_business_hoursobject (TenantCallingWindow)yesThe 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.timezonestringyesAn IANA zone name. A zone that does not resolve is 422.
sms_business_hours.weeklyobject (CampaignWeeklyWindow)yesPer-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.monobject (CampaignDayWindow)noOne 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.startstringyes
sms_business_hours.weekly.mon.endstringyes
sms_business_hours.weekly.tueobject (CampaignDayWindow)noOne 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.startstringyes
sms_business_hours.weekly.tue.endstringyes
sms_business_hours.weekly.wedobject (CampaignDayWindow)noOne 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.startstringyes
sms_business_hours.weekly.wed.endstringyes
sms_business_hours.weekly.thuobject (CampaignDayWindow)noOne 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.startstringyes
sms_business_hours.weekly.thu.endstringyes
sms_business_hours.weekly.friobject (CampaignDayWindow)noOne 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.startstringyes
sms_business_hours.weekly.fri.endstringyes
sms_business_hours.weekly.satobject (CampaignDayWindow)noOne 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.startstringyes
sms_business_hours.weekly.sat.endstringyes
sms_business_hours.weekly.sunobject (CampaignDayWindow)noOne 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.startstringyes
sms_business_hours.weekly.sun.endstringyes

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-hours

Back to no hours (idempotent), which turns the auto-reply off. Same roles as PUT.

Responses

200 ​

Cleared.

FieldTypeRequiredDescription
sms_business_hoursnullyes

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-reply

The 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.

FieldTypeRequiredDescription
sms_auto_replySmsAutoReply | nullyes

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-reply

Appends 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)

FieldTypeRequiredDescription
bodystringyesThe text sent. It must name an opt-out word that inbound keyword handling honours and must not contain a prohibited term.
languagestringyesA language tag, en or es-MX style.
cooldown_secondsintegernoAt 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.

FieldTypeRequiredDescription
sms_auto_replyobject (SmsAutoReply)yesThe version of the SMS auto-reply currently in force.
sms_auto_reply.bodystringyes
sms_auto_reply.languagestringyes
sms_auto_reply.cooldown_secondsintegeryes
sms_auto_reply.set_bystring,null (uuid)yesThe dashboard user that saved it; null for an API key.
sms_auto_reply.updated_atstring (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/records

The 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:

numbercall statusSMS status
+13125550100completeddelivered
+13125550101busyundelivered
+13125550102no_answersent
+13125550103failedfailed

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

NameInTypeRequiredDescription
kindquerycall · smsno
limitqueryintegerno

Responses

200 ​

The test records.

FieldTypeRequiredDescription
environmenttestyes
recordsarray of objectyes
records[].idstring (uuid)yes
records[].environmenttestyes
records[].kindcall · smsyes
records[].to_numberstringyes
records[].outcomeanswered · busy · no_answer · failedyes
records[].statusstringyes
records[].client_refstring,nullno
records[].body_lengthinteger,nullno
records[].started_atstring (date-time)yes
records[].answered_atstring,null (date-time)no
records[].ended_atstring,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/messages

Ledger 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

NameInTypeRequiredDescription
directionqueryoutbound · inboundno
statusqueryqueued · sent · delivered · undelivered · failed · receivedno
campaign_idquerystring (uuid)no
debt_idquerystring (uuid)no
thread_idquerystring (uuid)noOnly the rows of one inbox thread (thread_id of /v1/sms/threads). A malformed id is 400; an id of another tenant matches nothing.
limitqueryintegernoPage size (default 100, max 1000).
cursorquerystringnoOpaque keyset cursor from the previous page's next_cursor.

Responses

200 ​

One page of ledger rows.

FieldTypeRequiredDescription
sms_messagesarray of SmsMessageyes
sms_messages[].idstring (uuid)no
sms_messages[].directionoutbound · inboundno
sms_messages[].statusqueued · sent · delivered · undelivered · failed · receivedno
sms_messages[].from_e164string | nullno
sms_messages[].to_e164stringno
sms_messages[].body_previewstring | nullno
sms_messages[].segmentsinteger | nullno
sms_messages[].client_refstring | nullno
sms_messages[].provider_idstring (uuid) | nullno
sms_messages[].provider_message_idstring | nullno
sms_messages[].campaign_idstring (uuid) | nullno
sms_messages[].debt_idstring (uuid) | nullno
sms_messages[].thread_idstring (uuid) | nullnoInbox thread of the row; null on rows written before threads existed.
sms_messages[].occurred_atstring (date-time)no
sms_messages[].corrects_idstring (uuid) | nullno
sms_messages[].recorded_atstring (date-time)no
next_cursorstring | nullyes

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/messages

Routes 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)

FieldTypeRequiredDescription
tostringyesDestination, E.164.
bodystringyesMessage text (include the STOP opt-out notice; no carrier-prohibited term, see above).
fromstringnoSender 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_refstringnoIdempotency key (default a fresh uuid).
debt_idstring (uuid)noAttribution (with campaign_id, also lands the durable gate_blocks row on a block).
campaign_idstring (uuid)no
consumer_refstringno
timezonestringnoIANA 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).

FieldTypeRequiredDescription
sms_messageSmsMessageView | nullyes
duplicatebooleanyes
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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The collapsed view.

FieldTypeRequiredDescription
sms_messageobject (SmsMessageView)yesThe COLLAPSED single-message view — current status, per-status timestamps, frozen compliance snapshot, FULL body, ordered transitions.
sms_message.idstring (uuid)no
sms_message.directionoutbound · inboundno
sms_message.statusqueued · sent · delivered · undelivered · failed · receivedno
sms_message.from_e164string | nullno
sms_message.to_e164stringno
sms_message.bodystring | nullno
sms_message.segmentsinteger | nullno
sms_message.client_refstring | nullno
sms_message.provider_idstring (uuid) | nullno
sms_message.provider_message_idstring | nullno
sms_message.campaign_idstring (uuid) | nullno
sms_message.debt_idstring (uuid) | nullno
sms_message.thread_idstring (uuid) | nullnoInbox thread of the message; null on messages written before threads existed.
sms_message.compliance_snapshotobject | nullno
sms_message.occurred_atstring (date-time)no
sms_message.timestampsobjectnoPer-status timestamps (e.g. queued, sent, delivered).
sms_message.transitionsarray of SmsMessageno
sms_message.transitions[].idstring (uuid)no
sms_message.transitions[].directionoutbound · inboundno
sms_message.transitions[].statusqueued · sent · delivered · undelivered · failed · receivedno
sms_message.transitions[].from_e164string | nullno
sms_message.transitions[].to_e164stringno
sms_message.transitions[].body_previewstring | nullno
sms_message.transitions[].segmentsinteger | nullno
sms_message.transitions[].client_refstring | nullno
sms_message.transitions[].provider_idstring (uuid) | nullno
sms_message.transitions[].provider_message_idstring | nullno
sms_message.transitions[].campaign_idstring (uuid) | nullno
sms_message.transitions[].debt_idstring (uuid) | nullno
sms_message.transitions[].thread_idstring (uuid) | nullnoInbox thread of the row; null on rows written before threads existed.
sms_message.transitions[].occurred_atstring (date-time)no
sms_message.transitions[].corrects_idstring (uuid) | nullno
sms_message.transitions[].recorded_atstring (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/threads

The 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

NameInTypeRequiredDescription
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
unreadquerybooleannotrue keeps only threads with an inbound message in the window.
limitqueryintegernoPage size (default 50, max 200).
cursorquerystringnoOpaque keyset cursor from the previous page's next_cursor.

Responses

200 ​

One page of threads.

FieldTypeRequiredDescription
sms_threadsarray of SmsThreadyes
sms_threads[].thread_idstring (uuid)yesOpaque identity of the thread; the only handle (no digest is ever returned).
sms_threads[].tenant_idstring (uuid)yes
sms_threads[].consumer_numberstringyesThe consumer's E.164 number, read off the last message.
sms_threads[].line_numberstringyesOur line's E.164 number.
sms_threads[].last_message_idstring (uuid)yes
sms_threads[].last_directionoutbound · inboundyes
sms_threads[].last_body_previewstring | nullyes
sms_threads[].last_message_atstring (date-time)yes
sms_threads[].unreadbooleanyestrue if the window holds an inbound message of the thread newer than the thread's last read event.
sms_threads[].assigned_tostring (uuid) | nullyesUser the thread was last assigned to (the user_id of its last assigned/unassigned event); null if never assigned or released.
next_cursorstring | nullyes
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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The thread.

FieldTypeRequiredDescription
sms_threadanyyesAn 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}/assign

Writes 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Request body (JSON, required)

FieldTypeRequiredDescription
user_idstring (uuid) | nullyesUser to assign the thread to; null releases it.

Responses

201 ​

The recorded event.

FieldTypeRequiredDescription
sms_thread_eventobject (SmsThreadEvent)yesOne append-only event of an inbox thread, as recorded.
sms_thread_event.thread_idstring (uuid)yes
sms_thread_event.kindassigned · unassigned · readyes
sms_thread_event.user_idstring (uuid) | nullyesThe assignee of an assigned event; null for unassigned and read.
sms_thread_event.actor_user_idstring (uuid)yesThe login that wrote the event.
sms_thread_event.occurred_atstring (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}/read

Writes 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

201 ​

The recorded event.

FieldTypeRequiredDescription
sms_thread_eventobject (SmsThreadEvent)yesOne append-only event of an inbox thread, as recorded.
sms_thread_event.thread_idstring (uuid)yes
sms_thread_event.kindassigned · unassigned · readyes
sms_thread_event.user_idstring (uuid) | nullyesThe assignee of an assigned event; null for unassigned and read.
sms_thread_event.actor_user_idstring (uuid)yesThe login that wrote the event.
sms_thread_event.occurred_atstring (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"
  }
}

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