Skip to content

SMS ​

Generated from DialerDigital/core commit 0fc18008b3c8 — spec openapi/dialer-v1.yaml v0.134.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/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 (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.

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

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

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 (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-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 (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/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 (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/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; 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)

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 (core#2415), 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 (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

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 (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/threads

The 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

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

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 (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}/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 (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}/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 (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"
  }
}

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