Appearance
SMS Messaging: Approved Templates, Conversational Inbox & Auto-Replies
Spanish version: Mensajería SMS.
SMS messaging in Dialer Digital provides a compliance-first messaging architecture for US debt collection agencies. The platform supports template-based and free-text SMS sends, a multi-user conversational inbox with append-only thread assignment and read states, and automated out-of-hours responses.
Availability: shipped in core since v0.94.0
The SMS messaging features documented here (templates with approval workflows, conversational inbox threads, and out-of-hours auto-replies) are published in core v0.94.0. The request and response schemas correspond strictly to the OpenAPI specification (openapi/dialer-v1.yaml). Confirm with your platform operator that your environment is running v0.94.0 or higher.
Architectural Pillars & Compliance Guardrails
All SMS messaging paths operate under strict, fail-closed compliance guardrails:
No Compliance Gate Bypass: Whether a message is sent directly via
POST /v1/sms/messages, from a conversational inbox thread, or automatically as an out-of-hours auto-reply, every send passes through the core compliance engine (Consent,DNC,QuietHours,SmsFrequency,SpendGuard, rate brakes, and spend caps).- Fail-Closed Behavior: If a consumer lacks consent, or if sending violates quiet hours or frequency limits, the send is refused or blocked (
422 compliance_blocked). When the send carriesdebt_idandcampaign_id, the block also lands a durablegate_blocksrow; zero traffic reaches the upstream SMS provider either way. - Keyword Exemption: Only system keyword replies (
STOP,HELP,START) triggered directly by inbound messages are exempt from pre-send compliance checks per CTIA guidelines. Auto-replies and agent inbox messages are never exempt. - One STOP acknowledgment per number and window: the opt-out itself is applied on every STOP, but the confirmation message is sent once per (tenant, consumer number) per window, so a third party looping STOP does not bill you one SMS per message. The window is
sms_stop_ack_window_seconds(deployment setting, envSMS_STOP_ACK_WINDOW_SECONDS, default86400s);0acknowledges every STOP. Backed bylib/dialer/sms/inbound.exandconfig/runtime.exsincore.
- Fail-Closed Behavior: If a consumer lacks consent, or if sending violates quiet hours or frequency limits, the send is refused or blocked (
Template Policy Enforcement: Tenants can enforce strict template usage via
PUT /v1/sms/template-policy(templates_only: true). When enabled, free-text sends return422 sms_templates_only.Mandatory Opt-Out Notice & Content Policy: All template drafts and out-of-hours auto-reply bodies must explicitly include an opt-out notice (e.g.
Reply STOP to opt out) and pass static content checks. Missing notices or carrier-prohibited terms are rejected with422on creation.Append-Only Audited State: Thread assignments, read receipts, template policies, and auto-reply configurations are recorded as append-only event logs or version histories. No message ledger or thread event record is ever mutated or deleted.
1. SMS Templates & Approval Workflow
SMS templates allow defining structured messages with variable placeholders (such as , , or ).
Roles and Permissions
- Draft creation & editing: Any valid API key or tenant login (
memberoraccount_admin). - Approval & Policy enforcement:
account_adminonly. Amemberordd_machine key attempting to approve a template or toggletemplates_onlypolicy receives403 role_forbidden.
Workflow Lifecycle
[ POST /v1/sms-templates ] ──(Creates draft)──> [ Status: draft ]
│
┌─────────────┴─────────────┐
▼ ▼
[ POST /v1/sms-templates/{id}/approve ] (account_admin) [ POST /v1/sms-templates/{id}/retire ]
│ │
▼ ▼
[ Status: approved ] [ Status: retired ]Endpoints
Create a Template Draft
POST /v1/sms-templates
Creates a new template draft. If name already exists, a new draft version is created.
Request Body:
json
{
"name": "payment-reminder",
"body": "Hi {{first_name}}, your balance of {{amount}} is due on {{due_date}}. Reply STOP to opt out.",
"variables": [
{ "name": "first_name", "type": "string" },
{ "name": "amount", "type": "number" },
{ "name": "due_date", "type": "date" }
]
}- Variables: Declared types must be
string(1–160 chars),number, ordate(ISO-8601). Every variable used inbodymust be declared invariables, and vice versa. - Validation: Body must contain opt-out notice and no prohibited terms.
List Template Versions
GET /v1/sms-templates
Lists template versions, ordered newest version first per name. Supports query filters status (draft, approved, retired) and name.
Get a Single Template Version
GET /v1/sms-templates/{id}
Returns the template details for the specified version ID.
Update a Draft
PATCH /v1/sms-templates/{id}
Updates body and/or variables of a draft template. Editing an approved or retired template returns 409 conflict (a new version draft must be created instead).
Approve a Template Draft
POST /v1/sms-templates/{id}/approve
Requires account_admin. Approves the template draft, recording approved_by and approved_at. Approving a non-draft returns 409 conflict.
Retire a Template Version
POST /v1/sms-templates/{id}/retire
Retires a template version. A retired version can no longer be used for sends. Retiring a draft discards it.
Template Policy ("Templates Only")
GET /v1/sms/template-policyPUT /v1/sms/template-policy
Enforces that all SMS sends for the tenant must use approved templates.
PUTrequiresaccount_admin.
Request Body (PUT):
json
{
"templates_only": true
}When templates_only is true, any attempt to send free-text SMS via POST /v1/sms/messages returns 422 sms_templates_only.
2. Conversational SMS Inbox (Threads)
The conversational inbox groups SMS messages between a consumer's mobile number and a tenant line into a single thread.
Keyset Window & Derived Read-Model
- Threads are derived on read from the underlying ledger over a sliding 31-day window.
- Message delivery status transitions do not alter thread positions; only original messages update thread activity.
- Messages sent before thread support was introduced carry no
thread_idand are queried through the flat ledger (GET /v1/sms/messages).
Endpoints
List Inbox Threads
GET /v1/sms/threads
Returns active SMS threads sorted newest-first by the last original message in the window.
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
from | string (ISO-8601) | no | Start of window (default: trailing 31 days). |
to | string (ISO-8601) | no | End of window. |
unread | boolean | no | true filters to threads with inbound messages received after the last team read event. |
limit | integer | no | Page size (1–200, default 50). |
cursor | string | no | Keyset pagination cursor. |
Each thread row carries assigned_to (the user of its last assign/unassign event, or null); the list has no filter by assignee.
Get Thread Detail & Matched Debts
GET /v1/sms/threads/{id}
Returns the thread details and includes debt_ids: an array of up to 200 tenant debt IDs matching the consumer's phone number via blind index. An empty debt_ids array indicates no matching debts exist for that number.
Assign a Thread
POST /v1/sms/threads/{id}/assign
Writes an append-only assigned or unassigned event signed by the authenticated dashboard login.
Request Body:
json
{
"user_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10"
}- Pass
nullasuser_idto release the assignment. - Role Rules:
member: May only assign the thread to themselves, or release a thread currently assigned to themselves. Attempting to assign to another user returns403 role_forbidden.account_admin: May assign the thread to any active user of the tenant.- API machine keys (
dd_) or agent tokens return403 role_forbidden(an assignment requires a human login identity). - Assigning to a disabled, non-existent, or cross-tenant user returns
422 invalid_assignee.
Mark Thread as Read
POST /v1/sms/threads/{id}/read
Appends a read event signed by the calling dashboard login. Clears the unread indicator for the entire tenant team for messages prior to the read timestamp. Requires a human login (ddu_); machine keys receive 403 role_forbidden.
List Messages in a Thread
GET /v1/sms/messages?thread_id={id}
Lists messages belonging to the specified thread.
Reply from Thread (Composer Send)
POST /v1/sms/messages
Sends an outbound message. If from is omitted, the platform resolves the provider's default_from_e164. If no default line is configured, the send fails with 422 from_required.
The sender must be your own number. Whichever applies (from, or the provider's default_from_e164) has to be an E.164 number and an ACTIVE number of the tenant: otherwise 422 sms_from_invalid (not E.164) or 422 sms_from_not_owned (valid, but not an active number of the account; register it under /v1/dids). With did_ownership_proof on enforce, an active number whose ownership is not verified is refused with 422 did_unverified. Nothing is queued and the provider is not contacted. The same rule applies to default_from_e164 when you register or change a provider (POST/PATCH /v1/sms/providers) and to the campaign sender (section 4). Backed by lib/dialer/api/v1_router.ex (sms_from_error/2) and lib/dialer/sms/ in core.
A destination with no rate is refused. When no sell rate covers the send's region, the send is refused with 422 no_rate, for prepaid and postpaid tenants alike. It is not a payment refusal: topping up changes nothing, and retrying unchanged is refused again until the platform prices that region. Backed by respond_sms_send/4 in lib/dialer/api/v1_router.ex and the core CHANGELOG entry on postpaid no_rate.
3. Out-of-Hours SMS Auto-Replies
Tenants can configure automatic replies for inbound SMS received outside established business hours.
Operational Requirements
An auto-reply is triggered only when all three conditions are satisfied:
- The operator flag
sms_auto_reply_enabledis set totrue. - Tenant business hours (
/v1/tenant/sms-business-hours) are configured. - An auto-reply template/body (
/v1/tenant/sms-auto-reply) is saved.
If any condition is missing or disabled, no automatic reply is generated.
Fail-Closed Behavior
- QuietHours & Compliance Gates: Auto-replies pass through
Sender.send_sms/2. If an inbound text arrives out-of-hours but the consumer is in a timezone underQuietHoursor lacks consent, the auto-reply is not sent and is logged ingate_blocks. Auto-replies are never queued for morning delivery. - Cooldown Brakes: Auto-replies enforce a configurable cooldown per thread (
cooldown_seconds, default 86,400s / 24 hours). A thread receives at most one auto-reply per cooldown window.
Endpoints
Get Business Hours
GET /v1/tenant/sms-business-hours
Returns the configured SMS business hours, or null if unconfigured.
Set Business Hours
PUT /v1/tenant/sms-business-hours
Requires account_admin or dd_ machine key. Sets the weekly schedule for SMS business hours.
Request Body:
json
{
"timezone": "America/Chicago",
"weekly": {
"mon": { "start": "09:00", "end": "17:00" },
"tue": { "start": "09:00", "end": "17:00" },
"wed": { "start": "09:00", "end": "17:00" },
"thu": { "start": "09:00", "end": "17:00" },
"fri": { "start": "09:00", "end": "17:00" }
}
}Clear Business Hours
DELETE /v1/tenant/sms-business-hours
Requires account_admin or dd_ machine key. Removes business hours (disabling auto-replies).
Get Auto-Reply Configuration
GET /v1/tenant/sms-auto-reply
Returns the active auto-reply version, or null if never saved.
Save Auto-Reply Version
PUT /v1/tenant/sms-auto-reply
Requires account_admin or dd_ machine key. Saves a new version of the auto-reply text and cooldown.
Request Body:
json
{
"body": "We are currently closed. We will respond during business hours. Reply STOP to opt out.",
"cooldown_seconds": 86400,
"language": "en"
}- Validation: The body must contain an explicit opt-out notice and no prohibited terms (
422). Iftemplates_onlypolicy is enabled, saving free-text returns422 sms_templates_only.
4. Campaign SMS: why a campaign sends nothing
The sender of an SMS campaign (its caller_ids, its caller_id_pool or, failing both, the provider's default_from_e164) follows the same rule as a direct send: it must be an ACTIVE number of the tenant. A campaign with no usable sender does not fail: it ticks and sends nothing. GET /v1/campaigns/{id}/stats (and the campaign.stats frame) explains it in stats.send_unavailable_reason: why the last tick sent nothing, or null when it did not stop there. The closed list, read from openapi/dialer-v1.yaml in core:
| Value | Meaning |
|---|---|
provider_unresolved | No SMS provider resolves for the account or site; configure one. |
caller_ids_no_active_did | None of the campaign's caller_ids is an ACTIVE number of the account. |
caller_id_pool_no_active_did | The campaign's caller_id_pool has no active number. |
default_from_invalid | The campaign has no sender of its own and the provider's default_from_e164 is not an E.164 number the account owns. |
did_unverified | did_ownership_proof is enforce and the only active numbers the campaign would send from have no proven ownership. |
outside_calling_window | The tick fell outside the campaign's calling window; nothing is sent until the window opens (not set when calling_window_destination_local is on, where the window is decided per lead). |
It is cleared by the first later tick that passes the gates.
HTTP Error Codes & Contract Reference
| Status Code | Error code | Cause |
|---|---|---|
400 | bad_request | Malformed JSON body, window > 31 days in threads query, missing required field (user_id). |
401 | unauthorized | Missing, invalid, or expired Bearer token. |
402 | wallet_insufficient | Prepaid balance is too low for this send. Top up the wallet, then retry. |
402 | plan_exhausted | The SMS allowance included in the plan is used up. Upgrade the plan (or wait for the next period), then retry. |
402 | exposure_limit | The pre-verification exposure limit is reached. Complete the account verification (KYC), then retry. |
403 | role_forbidden | Non-admin user attempting template approval, policy update, business hours change, or assigning thread to another user; or machine key attempting assign/read. |
403 | synthetic_tenant | Send attempted from a synthetic demo tenant, which originates no outbound traffic. No payment changes this; use a real tenant. |
404 | not_found | Resource missing or belongs to another tenant. |
409 | conflict | Attempting to edit or approve a non-draft template version, or retiring an already retired version. |
422 | sms_templates_only | Free-text send or auto-reply attempted when tenant policy requires approved templates. |
422 | from_required | Send attempted without from line when provider has no default_from_e164. |
422 | sms_from_invalid | The sender (from, or the provider's default_from_e164) is not an E.164 number. |
422 | sms_from_not_owned | The sender is E.164 but not an ACTIVE number of the tenant. |
422 | did_unverified | The sender is an active number of the tenant, but did_ownership_proof is enforce and its ownership is not verified. |
422 | no_rate | No sell rate covers the destination region; refused, and topping up does not change it. |
422 | invalid_assignee | Thread assignment attempted to a disabled, non-existent, or cross-tenant user. |
422 | compliance_blocked | Send blocked by compliance engine (Consent, DNC, QuietHours, SmsFrequency). |
429 | throttled / sms_spend_cap_exceeded | Rate limit or tenant monthly SMS spend cap reached. |