twilio-sms
twilio-sms
Purpose
Enable OpenClaw to implement and operate Twilio Programmable Messaging (SMS/MMS) in production:
- Send SMS/MMS reliably (Messaging Services, geo-matching, sticky sender, media constraints).
- Receive inbound messages via webhooks and respond with TwiML.
- Track delivery lifecycle via status callbacks (queued/sent/delivered/undelivered/failed).
- Implement opt-out/STOP compliance and keyword workflows.
- Operate A2P 10DLC (US long code) and toll-free verification constraints.
- Debug and harden: signature validation, retries, idempotency, rate limits, carrier errors.
This skill is for engineers building messaging pipelines, customer notifications, 2-way support, and compliance-sensitive messaging.
Prerequisites
Accounts & Twilio Console setup
- Twilio account with Programmable Messaging enabled.
- At least one of:
- Messaging Service (recommended) with sender pool (long codes / toll-free / short code).
- A dedicated Phone Number capable of SMS/MMS.
- For US A2P 10DLC:
- Brand + Campaign registration completed in Twilio Console (Messaging → Regulatory Compliance / A2P 10DLC).
- For toll-free:
- Toll-free verification submitted/approved if sending high volume to US/CA.
Runtime versions (tested)
- Node.js 20.11.1 (LTS) + npm 10.2.4
- Python 3.11.7
- Twilio SDKs:
twilio(Node) 4.23.0twilio(Python) 9.4.1
- Web framework examples:
- Express 4.18.3
- FastAPI 0.109.2 + Uvicorn 0.27.1
- Optional tooling:
- Twilio CLI 5.16.0
- ngrok 3.13.1 (local webhook tunneling)
- Docker 25.0.3 + Compose v2 2.24.6
Credentials & auth
You need:
TWILIO_ACCOUNT_SID(starts withAC...)TWILIO_AUTH_TOKEN- One of:
TWILIO_MESSAGING_SERVICE_SID(starts withMG...) preferredTWILIO_FROM_NUMBER(E.164, e.g.+14155552671)
Store secrets in a secret manager (AWS Secrets Manager / GCP Secret Manager / Vault). For local dev, .env is acceptable.
Network & webhook requirements
- Public HTTPS endpoint for inbound and status callbacks.
- Must accept
application/x-www-form-urlencoded(Twilio default) and/or JSON depending on endpoint. - Validate Twilio signatures (
X-Twilio-Signature) on inbound webhooks.
Core Concepts
Programmable Messaging objects
- Message: a single outbound or inbound SMS/MMS. Identified by
MessageSid(SM...). - Messaging Service: abstraction over senders; supports:
- sender pool
- geo-matching
- sticky sender
- smart encoding
- status callback configuration
- Status Callback: webhook invoked as message state changes.
- Inbound Webhook: webhook invoked when Twilio receives an inbound message to your number/service.
Delivery lifecycle (practical)
Typical MessageStatus values you will see:
queued→sending→sent→delivered- Failure paths:
undelivered(carrier rejected / unreachable)failed(Twilio could not send; configuration/auth issues)
Treat sent as “handed to carrier”, not “delivered”.
TwiML for Messaging
Inbound SMS/MMS webhooks can respond with TwiML:
<Response>
<Message>Thanks. We received your message.</Message>
</Response>
Use TwiML for synchronous replies; use REST API for async workflows.
Opt-out compliance
- Twilio automatically handles standard opt-out keywords (e.g.,
STOP,UNSUBSCRIBE). - When a user opts out, Twilio blocks further messages from that sender/service to that recipient until they opt back in (
START). - Your app should:
- treat opt-out as a first-class state
- avoid retry storms on blocked recipients
- log and suppress sends to opted-out numbers
A2P 10DLC / short codes / toll-free
- US A2P 10DLC: required for application-to-person messaging over US long codes at scale. Unregistered traffic may be filtered or blocked.
- Short codes: high throughput, expensive, long provisioning.
- Toll-free: good for US/CA; verification improves deliverability and throughput.
Webhook retries and idempotency
Twilio retries webhooks on non-2xx responses. Your webhook handlers must be:
- idempotent (dedupe by
MessageSid/SmsSid) - fast (respond quickly; enqueue work)
- resilient (return 2xx once accepted)
Installation & Setup
Official Python SDK — Messaging
Repository: https://github.com/twilio/twilio-python
PyPI: pip install twilio · Supported: Python 3.7–3.13
from twilio.rest import Client
client = Client() # TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN from env
# Send SMS
msg = client.messages.create(
body="Hello from Python!",
from_="+15017250604",
to="+15558675309"
)
print(msg.sid)
# List recent messages
for m in client.messages.list(limit=20):
print(m.body, m.status)
Source: twilio/twilio-python — messages
Ubuntu 22.04 (x86_64 / ARM64)
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg jq
Node.js 20 via NodeSource:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v # v20.11.1 (or later 20.x)
npm -v # 10.2.4 (or later)
Python 3.11:
sudo apt-get install -y python3.11 python3.11-venv python3-pip
python3.11 --version
Twilio CLI 5.16.0:
npm install -g twilio-cli@5.16.0
twilio --version
ngrok 3.13.1 (optional):
curl -fsSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc \
| sudo gpg --dearmor -o /usr/share/keyrings/ngrok.gpg
echo "deb [signed-by=/usr/share/keyrings/ngrok.gpg] https://ngrok-agent.s3.amazonaws.com buster main" \
| sudo tee /etc/apt/sources.list.d/ngrok.list
sudo apt-get update && sudo apt-get install -y ngrok
ngrok version
Fedora 39 (x86_64 / ARM64)
sudo dnf install -y curl jq nodejs python3.11 python3.11-pip
node -v
python3.11 --version
Twilio CLI:
sudo npm install -g twilio-cli@5.16.0
twilio --version
macOS (Intel + Apple Silicon)
Homebrew:
brew update
brew install node@20 python@3.11 jq
Ensure PATH:
echo 'export PATH="/opt/homebrew/opt/node@20/bin:/opt/homebrew/opt/python@3.11/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
node -v
python3.11 --version
Twilio CLI:
npm install -g twilio-cli@5.16.0
twilio --version
ngrok:
brew install ngrok/ngrok/ngrok
ngrok version
Docker (all platforms)
docker --version
docker compose version
Twilio CLI authentication
Interactive login:
twilio login
Or set env vars (CI):
export TWILIO_ACCOUNT_SID="YOUR_ACCOUNT_SID"
export TWILIO_AUTH_TOKEN="your_auth_token"
Verify:
twilio api:core:accounts:fetch --sid "$TWILIO_ACCOUNT_SID"
Local webhook tunneling (ngrok)
ngrok http 3000
# note the https forwarding URL, e.g. https://f3a1-203-0-113-10.ngrok-free.app
Configure Twilio inbound webhook to:
https://.../twilio/inbound- Status callback to:
https://.../twilio/status
Key Capabilities
Send SMS/MMS (REST API)
- Use Messaging Service (
messagingServiceSid) for production. - Use
statusCallbackfor delivery receipts. - Use
provideFeedback=truefor carrier feedback (where supported).
Node (SMS):
import twilio from "twilio";
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
const msg = await client.messages.create({
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
to: "+14155550123",
body: "Build 742 deployed. Reply STOP to opt out.",
statusCallback: "https://api.example.com/twilio/status",
provideFeedback: true,
});
console.log(msg.sid, msg.status);
Python (MMS):
from twilio.rest import Client
import os
client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
msg = client.messages.create(
messaging_service_sid=os.environ["TWILIO_MESSAGING_SERVICE_SID"],
to="+14155550123",
body="Here is the incident screenshot.",
media_url=["https://cdn.example.com/incidents/INC-2048.png"],
status_callback="https://api.example.com/twilio/status",
)
print(msg.sid, msg.status)
Production constraints for MMS:
- Media must be publicly reachable via HTTPS.
- Content-type and size limits vary by carrier; keep images small (< 500KB) when possible.
- Use signed URLs with sufficient TTL (>= 1 hour) if private.
Receive inbound SMS/MMS (webhook)
Inbound webhook receives form-encoded fields like:
From,To,BodyMessageSid(orSmsSidlegacy)NumMedia,MediaUrl0,MediaContentType0, ...
Express handler with signature validation:
import express from "express";
import twilio from "twilio";
const app = express();
// Twilio sends application/x-www-form-urlencoded by default
app.use(express.urlencoded({ extended: false }));
app.post("/twilio/inbound", (req, res) => {
const signature = req.header("X-Twilio-Signature") || "";
const url = "https://api.example.com/twilio/inbound"; // must match public URL exactly
const isValid = twilio.validateRequest(
process.env.TWILIO_AUTH_TOKEN,
signature,
url,
req.body
);
if (!isValid) {
return res.status(403).send("Invalid signature");
}
const from = req.body.From;
const body = (req.body.Body || "").trim();
// Fast path: respond immediately; enqueue work elsewhere
const twiml = new twilio.twiml.MessagingResponse();
if (body.toUpperCase() === "HELP") {
twiml.message("Support: https://status.example.com. Reply STOP to opt out.");
} else {
twiml.message("Received. Ticket created.");
}
res.type("text/xml").send(twiml.toString());
});
app.listen(3000);
FastAPI handler:
from fastapi import FastAPI, Request, Response
from twilio.request_validator import RequestValidator
from twilio.twiml.messaging_response import MessagingResponse
import os
app = FastAPI()
validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])
@app.post("/twilio/inbound")
async def inbound(request: Request):
form = await request.form()
signature = request.headers.get("X-Twilio-Signature", "")
url = "https://api.example.com/twilio/inbound"
if not validator.validate(url, dict(form), signature):
return Response("Invalid signature", status_code=403)
body = (form.get("Body") or "").strip()
resp = MessagingResponse()
resp.message("Received.")
return Response(str(resp), media_type="text/xml")
Delivery receipts (status callback webhook)
Status callback receives:
MessageSidMessageStatus(queued,sent,delivered,undelivered,failed)To,FromErrorCode(e.g.,30003)ErrorMessage(sometimes present)
Express:
app.post("/twilio/status", express.urlencoded({ extended: false }), async (req, res) => {
// Validate signature same as inbound; use exact public URL
const messageSid = req.body.MessageSid;
const status = req.body.MessageStatus;
const errorCode = req.body.ErrorCode ? Number(req.body.ErrorCode) : null;
// Idempotency: upsert by messageSid + status
// Example: write to DB with unique constraint (messageSid, status)
console.log({ messageSid, status, errorCode });
res.status(204).send();
});
Operational guidance:
- Treat callbacks as at-least-once delivery.
- Persist state transitions; do not assume ordering.
- Use callbacks to:
- mark notifications delivered
- trigger fallback channels (email/push) on
undelivered/failed - compute deliverability metrics by carrier/region
Opt-out / STOP handling
Twilio blocks messages to opted-out recipients automatically. Your system should:
- Detect opt-out keywords on inbound messages and update your own contact preferences.
- Suppress sends to opted-out recipients to avoid repeated 21610 errors.
Inbound keyword handling:
const normalized = body.trim().toUpperCase();
const isStop = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"].includes(normalized);
const isStart = ["START", "YES", "UNSTOP"].includes(normalized);
if (isStop) {
// mark user opted out in your DB
}
if (isStart) {
// mark user opted in
}
When sending, pre-check your DB opt-out state. If you still hit Twilio block, handle error code 21610.
Messaging Services: sender pools, geo-matching, sticky sender
Use a Messaging Service to:
- avoid hardcoding
From - rotate senders safely
- enable geo-matching (local presence)
- enable sticky sender (consistent From per recipient)
Create a service (CLI):
twilio api:messaging:v1:services:create \
--friendly-name "prod-notifications" \
--status-callback "https://api.example.com/twilio/status"
Add a phone number to the service:
twilio api:messaging:v1:services:phone-numbers:create \
--service-sid YOUR_MG_SID \
--phone-number-sid PN0123456789abcdef0123456789abcdef
Enable sticky sender / geo-match (via API; CLI coverage varies by version):
twilio api:messaging:v1:services:update \
--sid YOUR_MG_SID \
--sticky-sender true \
--area-code-geomatch true
A2P 10DLC operational checks (US)
What to enforce in code:
- If sending to US numbers (
+1...) from US long codes:- ensure the sender is associated with an approved A2P campaign
- ensure message content matches campaign use case (avoid content drift)
- Monitor filtering:
- rising
30003/30005andundeliveredrates - carrier violations and spam flags
- rising
Twilio Console is the source of truth for registration state; in CI/CD, treat campaign IDs and service SIDs as config.
Short codes and toll-free
- Short code: high throughput, best deliverability; long lead time.
- Toll-free: good for US/CA; verification required for scale.
Implementation is identical at API level; difference is provisioning and compliance.
Webhook security: signature validation and URL correctness
Signature validation is brittle if:
- you validate against the wrong URL (must match the public URL Twilio used)
- you have proxies altering scheme/host
- you parse body differently than Twilio expects
If behind a reverse proxy (ALB/NGINX), reconstruct the public URL using forwarded headers carefully, or hardcode the known public URL per route.
Command Reference
Twilio CLI (5.16.0)
Authentication / environment
twilio login
twilio profiles:list
twilio profiles:use <profile>
Set env vars for a single command:
TWILIO_ACCOUNT_SID=AC... TWILIO_AUTH_TOKEN=... twilio api:core:accounts:fetch --sid AC...
Send a message (CLI)
Twilio CLI has a twilio api:core:messages:create command (core API). Common flags:
twilio api:core:messages:create \
--to "+14155550123" \
--from "+14155552671" \
--body "Deploy complete." \
--status-callback "https://api.example.com/twilio/status" \
--provide-feedback true \
--max-price 0.015 \
--application-sid AP0123456789abcdef0123456789abcdef
Notes on flags:
--to(required): destination E.164.--from: sender number (E.164). Prefer Messaging Service instead.--messaging-service-sid MG...: use service; mutually exclusive with--from.--body: SMS text.--media-url: repeatable; MMS media URL(s).--status-callback: webhook for status updates.--provide-feedback: request carrier feedback (not always available).--max-price: cap price (USD) for message; may cause failures if too low.--application-sid: for TwiML app association (rare for Messaging).
MMS example:
twilio api:core:messages:create \
--to "+14155550123" \
--messaging-service-sid YOUR_MG_SID \
--body "Photo" \
--media-url "https://cdn.example.com/a.png" \
--media-url "https://cdn.example.com/b.jpg"
Fetch message:
twilio api:core:messages:fetch --sid SM0123456789abcdef0123456789abcdef
List messages (filters vary; core ones):
twilio api:core:messages:list --limit 50
twilio api:core:messages:list --to "+14155550123" --limit 20
twilio api:core:messages:list --from "+14155552671" --limit 20
Delete message record (rare; mostly for cleanup/testing):
twilio api:core:messages:remove --sid SM0123456789abcdef0123456789abcdef
Messaging Services
Create:
twilio api:messaging:v1:services:create \
--friendly-name "prod-notifications" \
--status-callback "https://api.example.com/twilio/status"
Update:
twilio api:messaging:v1:services:update \
--sid YOUR_MG_SID \
--friendly-name "prod-notifications" \
--status-callback "https://api.example.com/twilio/status" \
--inbound-request-url "https://api.example.com/twilio/inbound" \
--inbound-method POST
List:
twilio api:messaging:v1:services:list --limit 50
Fetch:
twilio api:messaging:v1:services:fetch --sid YOUR_MG_SID
Phone numbers attached to a service:
twilio api:messaging:v1:services:phone-numbers:list \
--service-sid YOUR_MG_SID \
--limit 50
Attach a number:
twilio api:messaging:v1:services:phone-numbers:create \
--service-sid YOUR_MG_SID \
--phone-number-sid PN0123456789abcdef0123456789abcdef
Remove a number from service:
twilio api:messaging:v1:services:phone-numbers:remove \
--service-sid YOUR_MG_SID \
--sid PN0123456789abcdef0123456789abcdef
Incoming phone numbers (to find PN SIDs)
List numbers:
twilio api:core:incoming-phone-numbers:list --limit 50
Fetch:
twilio api:core:incoming-phone-numbers:fetch --sid PN0123456789abcdef0123456789abcdef
Update webhook on a number (if not using Messaging Service inbound URL):
twilio api:core:incoming-phone-numbers:update \
--sid PN0123456789abcdef0123456789abcdef \
--sms-url "https://api.example.com/twilio/inbound" \
--sms-method POST \
--sms-fallback-url "https://api.example.com/twilio/fallback" \
--sms-fallback-method POST \
--status-callback "https://api.example.com/twilio/status"
Configuration Reference
Environment variables
Recommended variables:
TWILIO_ACCOUNT_SIDTWILIO_AUTH_TOKENTWILIO_MESSAGING_SERVICE_SID(preferred)TWILIO_FROM_NUMBER(only if not using service)TWILIO_STATUS_CALLBACK_URLTWILIO_INBOUND_WEBHOOK_PUBLIC_URL(for signature validation)
Node.js .env
Path: /srv/app/.env (Linux) or project root for local dev.
TWILIO_ACCOUNT_SID=YOUR_ACCOUNT_SID
TWILIO_AUTH_TOKEN=0123456789abcdef0123456789abcdef
TWILIO_MESSAGING_SERVICE_SID=YOUR_MG_SID
TWILIO_STATUS_CALLBACK_URL=https://api.example.com/twilio/status
TWILIO_INBOUND_WEBHOOK_PUBLIC_URL=https://api.example.com/twilio/inbound
Load with dotenv:
npm install dotenv@16.4.5
import "dotenv/config";
systemd unit (production)
Path: /etc/systemd/system/messaging-api.service
[Unit]
Description=Messaging API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=messaging
Group=messaging
WorkingDirectory=/srv/messaging-api
EnvironmentFile=/etc/messaging-api/env
ExecStart=/usr/bin/node /srv/messaging-api/dist/server.js
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/messaging-api /var/log/messaging-api
AmbientCapabilities=
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
Secrets file path: /etc/messaging-api/env (chmod 600)
sudo install -m 600 -o root -g root /dev/null /etc/messaging-api/env
Example /etc/messaging-api/env:
TWILIO_ACCOUNT_SID=YOUR_ACCOUNT_SID
TWILIO_AUTH_TOKEN=0123456789abcdef0123456789abcdef
TWILIO_MESSAGING_SERVICE_SID=YOUR_MG_SID
TWILIO_STATUS_CALLBACK_URL=https://api.example.com/twilio/status
TWILIO_INBOUND_WEBHOOK_PUBLIC_URL=https://api.example.com/twilio/inbound
PORT=3000
NGINX reverse proxy (webhook endpoints)
Path: /etc/nginx/conf.d/messaging-api.conf
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location /twilio/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 10s;
}
}
Important: signature validation depends on the URL Twilio used; ensure your app uses the external URL, not http://127.0.0.1.
Integration Patterns
Pattern: Outbound notifications with delivery-driven fallback
Pipeline:
- Send SMS with
statusCallback. - On callback:
- if
delivered: mark success. - if
undelivered/failed: enqueue email via SendGrid or push notification.
- if
Pseudo-architecture:
- API service: accepts “notify user” request.
- Queue: stores send jobs.
- Worker: sends Twilio message.
- Webhook service: processes status callbacks and triggers fallback.
Example (status callback → SQS):
// on /twilio/status
if (status === "undelivered" || status === "failed") {
await sqs.sendMessage({
QueueUrl: process.env.FALLBACK_QUEUE_URL,
MessageBody: JSON.stringify({ messageSid, to: req.body.To, reason: req.body.ErrorCode }),
});
}
Pattern: Two-way support with ticketing
Inbound SMS:
- Validate signature.
- Normalize sender (
From) and map to customer. - Create ticket in Jira/ServiceNow/Zendesk.
- Reply with ticket ID via TwiML.
Example TwiML reply:
<Response>
<Message>Your ticket INC-2048 is open. Reply HELP for options.</Message>
</Response>
Pattern: Keyword-based workflows (HELP/STOP/START + custom)
HELP: return support URL and contact.STOP: update internal preference store (Twilio also blocks).- Custom keywords:
STATUS <id>,ONCALL,ACK <incident>.
Ensure parsing is robust and case-insensitive; log raw inbound payload for audit.
Pattern: Multi-region sending with geo-matching
- Use a single Messaging Service with:
- sender pool across regions
- geo-matching enabled
- For compliance, route by recipient country:
- US/CA: toll-free or A2P 10DLC long code
- UK: alphanumeric sender ID (if supported) or local number
- India: DLT constraints (outside scope here; treat as separate compliance module)
Pattern: Idempotent send API
If your upstream retries, you must avoid duplicate SMS.
Approach:
- Accept
idempotencyKeyfrom caller. - Store mapping
idempotencyKey -> MessageSid. - If key exists, return existing
MessageSid.
Example DB constraint:
- Unique index on
idempotency_key.
Error Handling & Troubleshooting
Handle errors at two layers:
- REST API call errors (synchronous)
- Status callback errors (asynchronous delivery failures)
Below are common Twilio errors with root cause and fix.
1) 21211 — invalid To number
Error text (typical):
Twilio could not find a Channel with the specified From address
or:
The 'To' number +1415555 is not a valid phone number.
Root causes:
- Not E.164 formatted.
- Contains spaces/parentheses.
- Invalid country code.
Fix:
- Normalize to E.164 before sending.
- Validate with libphonenumber.
- Reject at API boundary with clear error.
2) 20003 — authentication error
Error text:
Authenticate
or:
Unable to create record: Authenticate
Root causes:
- Wrong
TWILIO_AUTH_TOKEN. - Using test credentials against live endpoint or vice versa.
- Account SID mismatch.
Fix:
- Verify env vars in runtime.
- Rotate token if leaked.
- In CI, ensure correct secret scope.
3) 20429 — rate limit exceeded
Error text:
Too Many Requests
Root causes:
- Bursty sends exceeding Twilio or Messaging Service limits.
- Excessive API polling.
Fix:
- Implement client-side rate limiting and backoff (exponential with jitter).
- Batch sends via queue workers.
- Use Messaging Service / short code for higher throughput.
4) 21610 — recipient opted out
Error text:
The message From/To pair violates a blacklist rule.
Root causes:
- Recipient replied STOP (or carrier-level block).
- Your system keeps retrying.
Fix:
- Suppress sends to opted-out recipients in your DB.
- Provide opt-in flow (START).
- Do not retry 21610; treat as terminal.
5) 30003 — Unreachable destination handset / carrier violation
Error text (common):
Unreachable destination handset
Root causes:
- Device off/out of coverage.
- Carrier filtering.
- Invalid or deactivated number.
Fix:
- Treat as non-retryable after limited attempts.
- Trigger fallback channel.
- Monitor spikes by carrier/country; check A2P registration and content.
6) 30005 — Unknown destination handset
Error text:
Unknown destination handset
Root causes:
- Number not assigned.
- Porting issues.
Fix:
- Mark number invalid after repeated failures.
- Ask user to update phone number.
7) 21614 — 'To' is not a valid mobile number (MMS)
Error text:
'To' number is not a valid mobile number
Root causes:
- Attempting MMS to a number/carrier that doesn’t support MMS.
- Landline.
Fix:
- Detect MMS capability; fallback to SMS with link.
- Use Lookup (Twilio Lookup API) if you must preflight (cost tradeoff).
8) 21606 — From number not capable of sending SMS
Error text:
The From phone number +14155552671 is not a valid, SMS-capable inbound phone number or short code for your account.
Root causes:
- Using a voice-only number.
- Number not owned by your account.
- Wrong sender configured.
Fix:
- Use Messaging Service with verified senders.
- Confirm number capabilities in Console or via IncomingPhoneNumbers API.
9) Webhook signature failures (your logs)
Typical app log:
Invalid signature
Root causes:
- Validating against wrong URL (http vs https, host mismatch, path mismatch).
- Proxy rewrites.
- Body parsing differences.
Fix:
- Validate against the exact public URL configured in Twilio.
- Ensure
express.urlencoded()is used for form payloads. - If behind proxy, set
app.set('trust proxy', true)and reconstruct URL carefully.
10) Status callback not firing
Symptoms:
- Message shows delivered in console but your system never receives callback.
Root causes:
statusCallbacknot set on message or service.- Callback URL returns non-2xx; Twilio retries then gives up.
- Firewall blocks Twilio IPs (don’t IP allowlist unless you maintain Twilio ranges).
Fix:
- Set status callback at Messaging Service level.
- Ensure endpoint returns 2xx quickly.
- Log request bodies and response codes; add metrics.
Security Hardening
Secrets handling
- Do not store
TWILIO_AUTH_TOKENin repo. - Use secret manager + short-lived deployment injection.
- Rotate tokens on incident; treat as high-impact credential.
Webhook validation (mandatory)
- Validate
X-Twilio-Signaturefor inbound and status callbacks. - Reject invalid signatures with 403.
- Log minimal metadata; avoid logging full message bodies if sensitive.
TLS and transport
- Enforce HTTPS for all webhook endpoints.
- Disable TLS 1.0/1.1; prefer TLS 1.2+.
- If using NGINX, follow CIS NGINX Benchmark guidance for strong ciphers (adapt to your org baseline).
Request handling
- Limit request body size (protect against abuse):
- Express:
express.urlencoded({ limit: "64kb", extended: false })
- Express:
- Apply rate limiting on webhook endpoints (careful: Twilio retries; don’t block legitimate retries).
- Use WAF rules to block obvious abuse, but do not rely on IP allowlists.
Data minimization
- Store only what you need:
MessageSid,To,From, timestamps, status, error codes
- If storing message content, encrypt at rest and restrict access.
OS/service hardening (Linux)
- systemd hardening flags (see unit file above).
- Run as non-root user.
- Follow CIS Linux Benchmarks (Ubuntu 22.04 / RHEL/Fedora equivalents):
- restrict file permissions on env files (
chmod 600) - audit access to secrets
- enable automatic security updates where appropriate
- restrict file permissions on env files (
Performance Tuning
Reduce webhook latency (p95)
Goal: respond to Twilio webhooks in < 200ms p95.
Actions:
- Do not call external services synchronously in webhook handler.
- Enqueue work (SQS/Kafka/Redis) and return 204/200 immediately.
- Pre-parse and validate quickly; avoid heavy logging.
Expected impact:
- Before: webhook handler does DB + ticket creation → 1–3s p95, retries increase load.
- After: enqueue only → <100–200ms p95, fewer retries, lower Twilio webhook backlog.
Throughput scaling for outbound sends
- Use worker pool with concurrency control.
- Implement token bucket rate limiting per Messaging Service / sender type.
- Prefer Messaging Service with appropriate sender (short code/toll-free) for higher throughput.
Expected impact:
- Prevents 20429 spikes and reduces carrier filtering due to bursty patterns.
Cost optimization
- Use Messaging Service geo-matching to reduce cross-region costs where applicable.
- Avoid MMS when SMS + link suffices.
- Use
maxPricecarefully; too low increases failures.
Encoding and segmentation
- GSM-7 vs UCS-2 affects segment count and cost.
- If you send non-GSM characters (e.g., emoji, some accented chars), messages may switch to UCS-2 and segment at 70 chars.
Mitigation:
- Normalize content where acceptable.
- Keep messages short; move details to links.
Advanced Topics
Idempotency across retries and deploys
Twilio REST messages.create is not idempotent by default. If your worker retries after a timeout, you may double-send.
Mitigations:
- Use an application-level idempotency key stored in DB.
- On timeout, query by your own correlation ID (store in
statusCallbackquery string or inbodyis not safe). Better:- store job ID → message SID mapping once created
- if create call times out, attempt to detect if message was created by checking Twilio logs is unreliable; prefer conservative “may have sent” handling and alert.
Handling duplicate status callbacks
Twilio may send multiple callbacks for the same status or out of order.
- Store transitions with monotonic state machine:
queued<sending<sent<delivered- terminal failures:
undelivered/failed
- If you receive
deliveredafterundelivered, keep both but treat delivered as final if timestamp is later (rare but possible due to carrier reporting quirks).
Multi-tenant systems
If you operate multiple Twilio subaccounts:
- Store per-tenant Account SID/Auth Token (or use API Keys).
- Validate webhooks per tenant:
- signature validation uses the tenant’s auth token
- route by
Tonumber or by dedicated webhook URL per tenant
Media URL security for MMS
- Twilio fetches media from your URL; it must be reachable.
- If using signed URLs:
- TTL must exceed Twilio fetch window (minutes to tens of minutes; be conservative).
- Do not require cookies.
- If you require auth headers, Twilio cannot provide them; use pre-signed URLs.
Compliance gotchas
- Do not send marketing content from unregistered A2P campaigns.
- Maintain HELP/STOP responses and honor opt-out across channels if your policy requires it.
- Keep audit logs for consent (timestamp, source, IP/user agent if applicable).
Usage Examples
1) Production outbound SMS with Messaging Service + status tracking (Node)
Files:
/srv/messaging-api/src/send.js
import "dotenv/config";
import twilio from "twilio";
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
export async function sendDeployNotification({ to, buildId }) {
const msg = await client.messages.create({
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
to,
body: `Build ${buildId} deployed to prod. Reply HELP for support, STOP to opt out.`,
statusCallback: process.env.TWILIO_STATUS_CALLBACK_URL,
provideFeedback: true,
});
return { sid: msg.sid, status: msg.status };
}
Run:
node -e 'import("./src/send.js").then(m=>m.sendDeployNotification({to:"+14155550123",buildId:"742"}).then(console.log))'
2) Inbound SMS → create ticket → reply with TwiML (FastAPI)
/srv/messaging-api/app.py
from fastapi import FastAPI, Request, Response
from twilio.request_validator import RequestValidator
from twilio.twiml.messaging_response import MessagingResponse
import os
app = FastAPI()
validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])
def create_ticket(from_number: str, body: str) -> str:
# Replace with real integration
return "INC-2048"
@app.post("/twilio/inbound")
async def inbound(request: Request):
form = await request.form()
signature = request.headers.get("X-Twilio-Signature", "")
url = os.environ["TWILIO_INBOUND_WEBHOOK_PUBLIC_URL"]
if not validator.validate(url, dict(form), signature):
return Response("Invalid signature", status_code=403)
from_number = form.get("From") or ""
body = (form.get("Body") or "").strip()
ticket = create_ticket(from_number, body)
resp = MessagingResponse()
resp.message(f"Created {ticket}. Reply HELP for options. Reply STOP to opt out.")
return Response(str(resp), media_type="text/xml")
Run:
python3.11 -m venv .venv && source .venv/bin/activate
pip install fastapi==0.109.2 uvicorn==0.27.1 twilio==9.4.1 python-multipart==0.0.9
uvicorn app:app --host 0.0.0.0 --port 3000
3) Status callback → metrics + fallback enqueue (Express)
/srv/messaging-api/src/status.js
import express from "express";
import twilio from "twilio";
const app = express();
app.use(express.urlencoded({ extended: false, limit: "64kb" }));
app.post("/twilio/status", async (req, res) => {
const signature = req.header("X-Twilio-Signature") || "";
const url = process.env.TWILIO_STATUS_CALLBACK_PUBLIC_URL;
const ok = twilio.validateRequest(process.env.TWILIO_AUTH_TOKEN, signature, url, req.body);
if (!ok) return res.status(403).send("Invalid signature");
const { MessageSid, MessageStatus, ErrorCode, To } = req.body;
// Example: emit metric
console.log("twilio_status", { MessageSid, MessageStatus, ErrorCode });
if (MessageStatus === "failed" || MessageStatus === "undelivered") {
// enqueue fallback (pseudo)
console.log("enqueue_fallback", { to: To, messageSid: MessageSid, reason: ErrorCode });
}
res.status(204).send();
});
app.listen(3000);
4) MMS with signed URL (S3 presigned) + fallback to SMS link
Pseudo-flow:
- Generate presigned URL valid for 2 hours.
- Send MMS with media URL.
- If status callback returns
21614orundelivered, send SMS with link.
Python snippet:
msg = client.messages.create(
messaging_service_sid=os.environ["TWILIO_MESSAGING_SERVICE_SID"],
to="+14155550123",
body="Incident screenshot attached.",
media_url=[presigned_url], # TTL >= 2h
status_callback="https://api.example.com/twilio/status",
)
Fallback SMS body:
MMS failed on your carrier. View: https://app.example.com/incidents/INC-2048
5) Opt-out aware bulk send with concurrency + suppression
- Load recipients.
- Filter out opted-out in DB.
- Send with concurrency 20.
- On 21610, mark opted-out.
Node (sketch):
import pLimit from "p-limit";
import twilio from "twilio";
const limit = pLimit(20);
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
async function sendOne(to) {
try {
return await client.messages.create({
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
to,
body: "Maintenance tonight 01:00-02:00 UTC. Reply STOP to opt out.",
statusCallback: process.env.TWILIO_STATUS_CALLBACK_URL,
});
} catch (e) {
const code = e?.code;
if (code === 21610) {
// mark opted out
return null;
}
throw e;
}
}
await Promise.all(recipients.map((to) => limit(() => sendOne(to))));
Install:
npm install p-limit@5.0.0 twilio@4.23.0
Quick Reference
| Task | Command / API | Key flags / fields |
|---|---|---|
| Send SMS (CLI) | twilio api:core:messages:create |
--to, --from or --messaging-service-sid, --body, --status-callback, --provide-feedback, --max-price |
| Send MMS (CLI) | twilio api:core:messages:create |
--media-url (repeatable), --body |
| Fetch message | twilio api:core:messages:fetch --sid SM... |
--sid |
| List messages | twilio api:core:messages:list |
--to, --from, --limit |
| Create Messaging Service | twilio api:messaging:v1:services:create |
--friendly-name, --status-callback |
| Update service webhooks | twilio api:messaging:v1:services:update |
--inbound-request-url, --inbound-method, --status-callback |
| Attach number to service | twilio api:messaging:v1:services:phone-numbers:create |
--service-sid, --phone-number-sid |
| Inbound webhook | HTTP POST /twilio/inbound |
Validate X-Twilio-Signature, parse form fields |
| Status callback | HTTP POST /twilio/status |
MessageSid, MessageStatus, ErrorCode |
Graph Relationships
DEPENDS_ON
twilioSDK (Node 4.23.0 / Python 9.4.1)- Public HTTPS ingress (NGINX/ALB/API Gateway)
- Persistent store for idempotency and message state (Postgres/DynamoDB)
- Queue for async processing (SQS/Kafka/Redis Streams) recommended
COMPOSES
twilio-voice(IVR can trigger SMS follow-ups; missed-call → SMS)twilio-verify(OTP via SMS; share webhook infra and signature validation patterns)sendgrid-email(fallback channel on undelivered; unified notification service)observability(metrics/logging/tracing for webhook latency and delivery rates)
SIMILAR_TO
aws-sns-sms(SMS sending + delivery receipts, different semantics)messagebird-sms/vonage-sms(carrier routing + webhook patterns)firebase-cloud-messaging(delivery callbacks conceptually similar, different channel)