twilio-whatsapp
twilio-whatsapp
Purpose
Enable OpenClaw to implement and operate Twilio WhatsApp Business messaging in production:
- Send template messages (pre-approved) and session messages (24-hour customer care window).
- Attach media (images/docs/audio) with correct MIME types and size constraints.
- Receive and validate webhooks (incoming messages + message status callbacks).
- Implement opt-in/opt-out and compliance controls (STOP handling, consent logging, regional constraints).
- Operate reliably under Twilio production constraints: rate limits, retries, idempotency, error codes, and cost controls via Messaging Services.
Concrete value to an engineer: ship a WhatsApp messaging subsystem that is observable, compliant, resilient to webhook retries, and safe to run at scale with predictable failure modes.
Prerequisites
Accounts & Twilio-side setup
- Twilio account with WhatsApp sender enabled:
- Either Twilio Sandbox for WhatsApp (dev only) or a WhatsApp Business Profile connected to Twilio (prod).
- A Twilio Messaging Service (recommended for production) with:
- WhatsApp sender(s) attached (e.g.,
whatsapp:+14155238886or your approved WA number). - Status callback URL configured (optional but recommended).
- WhatsApp sender(s) attached (e.g.,
- WhatsApp templates approved in Meta Business Manager (via Twilio Console template manager).
Local tooling versions (pinned)
- Node.js 20.11.1 (LTS) or 18.19.1 (LTS)
- Python 3.11.8 or 3.12.2
- Twilio helper libraries:
twilio(Node) 4.23.0twilio(Python) 9.0.5
- Twilio CLI 5.16.0 (for diagnostics; not required at runtime)
- ngrok 3.13.1 (local webhook testing)
Auth & secrets
Use one of:
-
API Key (recommended):
TWILIO_API_KEY_SID(starts withSK...)TWILIO_API_KEY_SECRETTWILIO_ACCOUNT_SID(starts withAC...)
-
Account SID + Auth Token:
TWILIO_ACCOUNT_SIDTWILIO_AUTH_TOKEN
Store secrets in:
- Kubernetes:
Secret+ mounted env vars - AWS: Secrets Manager + IRSA
- GCP: Secret Manager + Workload Identity
- Local dev:
.env(never commit)
Network & webhook requirements
- Public HTTPS endpoint for webhooks (Twilio requires HTTPS in most production contexts).
- Allow inbound from Twilio webhook IPs is not stable; validate using X-Twilio-Signature instead of IP allowlists.
- Ensure your endpoint can handle retries and out-of-order delivery.
Core Concepts
WhatsApp message types (Twilio perspective)
-
Template message (outside 24-hour window):
- Must use a pre-approved template.
- Used for notifications, OTP, shipping updates, etc.
- In Twilio, templates are typically sent via the Content API (preferred) or via template integration depending on account configuration.
-
Session message (inside 24-hour window):
- Free-form text/media allowed (subject to WhatsApp policies).
- The 24-hour window starts when the user messages you.
-
Media message:
- WhatsApp supports images, documents, audio, video with constraints.
- Twilio sends media via
MediaUrl(publicly accessible URL) or via Twilio-hosted media in some flows.
Identifiers and addressing
- WhatsApp addresses in Twilio use
whatsapp:prefix:From:whatsapp:+14155238886To:whatsapp:+14155550123
- Messaging Service SID:
MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx - Message SID:
SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Webhooks
Two primary webhook categories:
-
Incoming message webhook (when user sends you a message):
- Twilio sends an HTTP request with form-encoded parameters like
From,To,Body,NumMedia,MediaUrl0, etc.
- Twilio sends an HTTP request with form-encoded parameters like
-
Message status callback (delivery lifecycle):
- Status values:
queued,sent,delivered,read,failed,undelivered - Twilio retries on non-2xx responses with backoff.
- Status values:
Idempotency and retries
- Twilio may retry webhooks; your handler must be idempotent.
- Status callbacks can arrive out of order (e.g.,
deliveredthenread, orfailedafter transient states). - Use
MessageSid+MessageStatus+ timestamp to dedupe.
Compliance: opt-in/opt-out
- WhatsApp requires user opt-in; you must store consent evidence.
- STOP handling:
- For SMS, Twilio has built-in STOP.
- For WhatsApp, you must implement opt-out keywords and respect them (e.g., “STOP”, “UNSUBSCRIBE”).
- Maintain a suppression list keyed by E.164 phone number.
Installation & Setup
Official Python SDK — WhatsApp
Repository: https://github.com/twilio/twilio-python
PyPI: pip install twilio · Supported: Python 3.7–3.13
from twilio.rest import Client
client = Client()
# Send WhatsApp message (Sandbox: from_ = 'whatsapp:+14155238886')
msg = client.messages.create(
body="Your order is confirmed!",
from_="whatsapp:+14155238886",
to="whatsapp:+15558675309"
)
# Send template message (approved HSM)
msg = client.messages.create(
from_="whatsapp:+14155238886",
to="whatsapp:+15558675309",
content_sid="HX...", # pre-approved template SID
content_variables='{"1":"Alice","2":"12345"}'
)
Source: twilio/twilio-python — messages
Ubuntu 22.04 LTS (x86_64)
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg jq
Node.js 20.11.1 via NodeSource:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v
npm -v
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:
curl -s https://ngrok-agent.s3.amazonaws.com/ngrok.asc \
| sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null
echo "deb 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)
sudo dnf install -y curl jq nodejs python3 python3-virtualenv
node -v
python3 --version
Twilio CLI:
sudo npm install -g twilio-cli@5.16.0
twilio --version
ngrok:
sudo dnf install -y ngrok
ngrok version
macOS 14 (Sonoma) — Intel + Apple Silicon
Homebrew:
brew update
brew install node@20 python@3.12 jq ngrok/ngrok/ngrok
node -v
python3 --version
ngrok version
Twilio CLI:
npm install -g twilio-cli@5.16.0
twilio --version
Auth setup (CLI + env)
Twilio CLI login (writes to ~/.twilio-cli/config.json):
twilio login
Runtime env vars (recommended: API Key):
export TWILIO_ACCOUNT_SID="AC2f7b9c2b0f1d2e3a4b5c6d7e8f9a0b1"
export TWILIO_API_KEY_SID="SK3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8"
export TWILIO_API_KEY_SECRET="a_very_long_secret_value"
export TWILIO_MESSAGING_SERVICE_SID="MG0c3d2e1f4a5b6c7d8e9f0a1b2c3d4e5"
Local .env (example path: /srv/whatsapp/.env):
TWILIO_ACCOUNT_SID=AC2f7b9c2b0f1d2e3a4b5c6d7e8f9a0b1
TWILIO_API_KEY_SID=SK3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8
TWILIO_API_KEY_SECRET=a_very_long_secret_value
TWILIO_MESSAGING_SERVICE_SID=MG0c3d2e1f4a5b6c7d8e9f0a1b2c3d4e5
PUBLIC_BASE_URL=https://wa.example.com
Key Capabilities
Send session messages (text + media)
- Use Twilio Programmable Messaging
MessagesAPI. - Ensure
ToandFromincludewhatsapp:prefix. - Prefer
MessagingServiceSidover hardcodingFromfor routing and future sender expansion.
Node (twilio 4.23.0):
import twilio from "twilio";
const client = twilio(
process.env.TWILIO_API_KEY_SID,
process.env.TWILIO_API_KEY_SECRET,
{ accountSid: process.env.TWILIO_ACCOUNT_SID }
);
export async function sendSessionText(toE164, body) {
const msg = await client.messages.create({
to: `whatsapp:${toE164}`,
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
body,
statusCallback: `${process.env.PUBLIC_BASE_URL}/twilio/status`
});
return msg.sid;
}
Python (twilio 9.0.5):
import os
from twilio.rest import Client
client = Client(
os.environ["TWILIO_API_KEY_SID"],
os.environ["TWILIO_API_KEY_SECRET"],
os.environ["TWILIO_ACCOUNT_SID"],
)
def send_session_media(to_e164: str, body: str, media_url: str) -> str:
msg = client.messages.create(
to=f"whatsapp:{to_e164}",
messaging_service_sid=os.environ["TWILIO_MESSAGING_SERVICE_SID"],
body=body,
media_url=[media_url],
status_callback=f"{os.environ['PUBLIC_BASE_URL']}/twilio/status",
)
return msg.sid
Operational constraints:
- Media URLs must be publicly reachable by Twilio (no private S3 URL unless presigned).
- If you send media, validate content-type and size before sending to reduce failures.
Send template messages (outside session window)
Production recommendation: use Twilio Content API (aka “Content Templates”) when available in your account. This decouples template definition from code and supports localization/variables.
Content API send (Messages API with contentSid)
Node:
export async function sendTemplate(toE164) {
const msg = await client.messages.create({
to: `whatsapp:${toE164}`,
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
contentSid: "HXb5b62575e6e4ff6129ad7c8efe1f983e",
contentVariables: JSON.stringify({
"1": "Ava",
"2": "Order #18473",
"3": "2026-02-21"
}),
statusCallback: `${process.env.PUBLIC_BASE_URL}/twilio/status`
});
return msg.sid;
}
Python:
import json
def send_template(to_e164: str) -> str:
msg = client.messages.create(
to=f"whatsapp:{to_e164}",
messaging_service_sid=os.environ["TWILIO_MESSAGING_SERVICE_SID"],
content_sid="HXb5b62575e6e4ff6129ad7c8efe1f983e",
content_variables=json.dumps({"1": "Ava", "2": "Order #18473", "3": "2026-02-21"}),
status_callback=f"{os.environ['PUBLIC_BASE_URL']}/twilio/status",
)
return msg.sid
Notes:
contentVariableskeys are strings"1","2", etc. per Twilio Content variable indexing.- Template approval and category (utility/marketing/authentication) affects deliverability and policy compliance.
Receive inbound WhatsApp messages (webhook handler)
Twilio sends application/x-www-form-urlencoded by default.
Express (Node):
import express from "express";
import twilio from "twilio";
const app = express();
app.use(express.urlencoded({ extended: false }));
app.post("/twilio/inbound", (req, res) => {
const signature = req.header("X-Twilio-Signature") || "";
const url = `${process.env.PUBLIC_BASE_URL}/twilio/inbound`;
const isValid = twilio.validateRequest(
process.env.TWILIO_AUTH_TOKEN, // validateRequest requires Auth Token, not API key secret
signature,
url,
req.body
);
if (!isValid) return res.status(403).send("invalid signature");
const from = req.body.From; // e.g. "whatsapp:+14155550123"
const body = req.body.Body || "";
const numMedia = parseInt(req.body.NumMedia || "0", 10);
// Idempotency: inbound messages have MessageSid
const messageSid = req.body.MessageSid;
// TODO: persist inbound event, dedupe by MessageSid
// TODO: implement opt-out keywords
res.type("text/xml").send("<Response></Response>");
});
app.listen(3000);
Important: validateRequest requires TWILIO_AUTH_TOKEN. If you use API Keys for REST calls, you still need Auth Token for webhook signature validation. Store it separately and restrict access.
FastAPI (Python):
import os
from fastapi import FastAPI, Request, Response
from twilio.request_validator import RequestValidator
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 = f"{os.environ['PUBLIC_BASE_URL']}/twilio/inbound"
if not validator.validate(url, dict(form), signature):
return Response(content="invalid signature", status_code=403)
message_sid = form.get("MessageSid")
from_ = form.get("From")
body = form.get("Body", "")
return Response(content="<Response></Response>", media_type="text/xml")
Message status callbacks (delivered/read/failed)
Configure statusCallback per message or at Messaging Service level.
Twilio will POST fields including:
MessageSid,MessageStatus,To,From,ErrorCode,ErrorMessage
Handler requirements:
- Always return 2xx quickly (under ~2 seconds).
- Enqueue processing to a job queue (SQS, Pub/Sub, Kafka).
- Dedupe by
(MessageSid, MessageStatus).
Example (Express):
app.post("/twilio/status", (req, res) => {
// Validate signature same as inbound
const { MessageSid, MessageStatus, ErrorCode, ErrorMessage } = req.body;
// Persist status transition; do not assume ordering
// If failed/undelivered, capture ErrorCode + ErrorMessage for triage
res.sendStatus(204);
});
Opt-in management and suppression
Implement:
- Consent capture (timestamp, source, IP/user agent if applicable, proof text).
- Suppression list:
- If user sends “STOP”, “UNSUBSCRIBE”, “CANCEL”, “END”, “QUIT” → mark suppressed.
- If user sends “START”, “UNSTOP”, “SUBSCRIBE” → unsuppress (only if policy allows).
Example keyword parsing:
STOP_WORDS = {"stop", "unsubscribe", "cancel", "end", "quit"}
START_WORDS = {"start", "unstop", "subscribe"}
def classify_opt(body: str) -> str | None:
t = body.strip().lower()
if t in STOP_WORDS:
return "STOP"
if t in START_WORDS:
return "START"
return None
Enforcement:
- Before sending any outbound message, check suppression list.
- For template messages, also check consent freshness and region-specific rules.
Media handling (upload, validation, and delivery)
Twilio requires MediaUrl accessible by Twilio. Common pattern:
- Store media in S3 with short-lived presigned URL (e.g., 15 minutes).
- Validate MIME type and size before generating URL.
Constraints vary; enforce conservative limits:
- Images: <= 5 MB
- Documents: <= 100 MB (PDF), but enforce smaller for reliability
- Audio/video: enforce <= 16 MB unless you have confirmed limits for your account/region
Example: generate presigned URL (AWS SDK v3, Node):
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: "us-east-1" });
export async function presign(bucket, key) {
const cmd = new GetObjectCommand({ Bucket: bucket, Key: key });
return await getSignedUrl(s3, cmd, { expiresIn: 900 });
}
Webhook replay protection and idempotency
Store processed webhook IDs:
- Inbound:
MessageSid - Status:
MessageSid + ":" + MessageStatus
Use a fast store (Redis) with TTL (e.g., 7 days) to prevent duplicate processing.
Redis example (pseudo):
SETNX twilio:inbound:SM... 1 EX 604800
SETNX twilio:status:SM...:delivered 1 EX 604800
Command Reference
Twilio CLI (5.16.0)
Authenticate
twilio login
Flags:
--profile <name>: store credentials under a named profile--username <AC...>: account SID--password <auth_token>: auth token (interactive if omitted)
List messages
twilio api:core:messages:list
Relevant flags:
--to <string>: filter by To (e.g.,whatsapp:+14155550123)--from <string>: filter by From--date-sent <YYYY-MM-DD>: filter by date--page-size <int>: default 50--limit <int>: max records to return--properties <csv>: select fields (CLI dependent)--output json|tsv|csv: output format (CLI dependent)
Example:
twilio api:core:messages:list --to "whatsapp:+14155550123" --limit 20 --output json | jq .
Fetch a message
twilio api:core:messages:fetch --sid SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Flags:
--sid <SM...>: required
Create a message (session or template via Content API)
twilio api:core:messages:create \
--to "whatsapp:+14155550123" \
--messaging-service-sid MG0c3d2e1f4a5b6c7d8e9f0a1b2c3d4e5 \
--body "Hello from production"
All relevant flags (commonly supported by API/CLI; availability may vary by CLI version):
--to <string>: required--from <string>: optional if using Messaging Service--messaging-service-sid <MG...>: recommended--body <string>: message text--media-url <url>: repeatable for multiple media--status-callback <url>: status webhook--max-price <decimal>: price cap (channel-dependent)--provide-feedback <boolean>: request delivery feedback (carrier dependent)--attempt <int>/--validity-period <int>: channel dependent; may not apply to WhatsApp--content-sid <HX...>: Content API template identifier--content-variables <json>: JSON string of variables
Example template send:
twilio api:core:messages:create \
--to "whatsapp:+14155550123" \
--messaging-service-sid MG0c3d2e1f4a5b6c7d8e9f0a1b2c3d4e5 \
--content-sid HXb5b62575e6e4ff6129ad7c8efe1f983e \
--content-variables '{"1":"Ava","2":"Order #18473","3":"2026-02-21"}'
Debug webhooks locally with ngrok
ngrok http 3000
Copy the HTTPS forwarding URL into:
- Twilio Console → Messaging → WhatsApp Sender / Messaging Service → Inbound webhook
- Status callback URL
Configuration Reference
Node service config
Path: /srv/whatsapp/config/whatsapp.production.toml
[twilio]
account_sid = "AC2f7b9c2b0f1d2e3a4b5c6d7e8f9a0b1"
messaging_service_sid = "MG0c3d2e1f4a5b6c7d8e9f0a1b2c3d4e5"
public_base_url = "https://wa.example.com"
[webhooks]
inbound_path = "/twilio/inbound"
status_path = "/twilio/status"
validate_signatures = true
signature_auth_token_env = "TWILIO_AUTH_TOKEN"
[opt]
stop_keywords = ["stop","unsubscribe","cancel","end","quit"]
start_keywords = ["start","unstop","subscribe"]
suppression_ttl_days = 3650
[media]
max_image_bytes = 5242880
max_doc_bytes = 26214400
presign_ttl_seconds = 900
allowed_mime_prefixes = ["image/","application/pdf"]
Path: /srv/whatsapp/.env (permissions 0600)
TWILIO_ACCOUNT_SID=AC2f7b9c2b0f1d2e3a4b5c6d7e8f9a0b1
TWILIO_API_KEY_SID=SK3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8
TWILIO_API_KEY_SECRET=a_very_long_secret_value
TWILIO_AUTH_TOKEN=your_auth_token_for_signature_validation
TWILIO_MESSAGING_SERVICE_SID=MG0c3d2e1f4a5b6c7d8e9f0a1b2c3d4e5
PUBLIC_BASE_URL=https://wa.example.com
systemd unit (Linux)
Path: /etc/systemd/system/whatsapp.service
[Unit]
Description=WhatsApp Messaging Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=whatsapp
Group=whatsapp
WorkingDirectory=/srv/whatsapp
EnvironmentFile=/srv/whatsapp/.env
ExecStart=/usr/bin/node /srv/whatsapp/dist/server.js
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/whatsapp /var/log/whatsapp
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
Kubernetes deployment snippet
Path: /srv/whatsapp/deploy/k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: whatsapp
namespace: messaging
spec:
replicas: 6
selector:
matchLabels:
app: whatsapp
template:
metadata:
labels:
app: whatsapp
spec:
containers:
- name: whatsapp
image: ghcr.io/acme/whatsapp:2026.02.21
ports:
- containerPort: 3000
env:
- name: TWILIO_ACCOUNT_SID
valueFrom:
secretKeyRef:
name: twilio
key: account_sid
- name: TWILIO_API_KEY_SID
valueFrom:
secretKeyRef:
name: twilio
key: api_key_sid
- name: TWILIO_API_KEY_SECRET
valueFrom:
secretKeyRef:
name: twilio
key: api_key_secret
- name: TWILIO_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: twilio
key: auth_token
- name: TWILIO_MESSAGING_SERVICE_SID
valueFrom:
secretKeyRef:
name: twilio
key: messaging_service_sid
- name: PUBLIC_BASE_URL
value: "https://wa.example.com"
readinessProbe:
httpGet:
path: /healthz
port: 3000
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1"
memory: "1Gi"
Integration Patterns
Pattern: API service + queue for webhook processing
- Webhook handler validates signature and enqueues event.
- Worker consumes events and updates DB, triggers downstream actions.
Example pipeline:
- Twilio →
POST /twilio/inbound - API → publish to Kafka topic
twilio.inbound.v1 - Worker → parse, apply opt-out, route to conversation service
- Conversation service → decides response → sends via Twilio Messages API
Kafka message schema (JSON):
{
"event_type": "twilio_inbound",
"received_at": "2026-02-21T18:22:11.123Z",
"message_sid": "SMd2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7",
"from": "whatsapp:+14155550123",
"to": "whatsapp:+14155238886",
"body": "Where is my order?",
"num_media": 0,
"raw": { "Body": "Where is my order?", "ProfileName": "Ava" }
}
Pattern: Compose with Twilio Verify (OTP over WhatsApp)
- Use Verify V2 with WhatsApp channel where supported.
- Fallback to SMS if WhatsApp fails.
Flow:
- Attempt Verify via WhatsApp
- If error indicates channel unavailable, fallback to SMS
Key operational note: Verify has its own rate limiting and fraud controls; do not DIY OTP over session messages unless you accept the compliance and abuse risk.
Pattern: Compose with SendGrid for email fallback
- If WhatsApp template fails with
63016(outside window / template required) or user opted out:- Send transactional email via SendGrid dynamic template.
- Keep a unified notification log with channel attempts and outcomes.
Pattern: Studio for rapid iteration, API for core flows
- Use Twilio Studio for low-risk flows (FAQ, routing).
- Use REST Trigger API to start Studio flows from your backend.
- Keep WhatsApp sending in code for high-volume, audited flows.
Error Handling & Troubleshooting
Handle Twilio errors at two layers:
- REST API errors when sending
- Webhook status callbacks with
ErrorCode/ErrorMessage
At minimum, log:
MessageSid,To,From,MessagingServiceSid,ErrorCode,ErrorMessage, HTTP status, Twilio request ID (X-Twilio-Request-Idif present)
1) 21211 — invalid To number
Error text (common):
TwilioRestException: The 'To' number +1415555012 is not a valid phone number.
Root cause:
- Not E.164, missing digits, or missing
whatsapp:prefix.
Fix:
- Normalize to E.164 and prefix:
To=whatsapp:+14155550123. - Validate with libphonenumber before calling Twilio.
2) 20003 — authentication failure
Error text:
Authenticate
The AccountSid and AuthToken combination you have provided is invalid.
Root cause:
- Wrong credentials, mixing API key secret with auth token, wrong account SID.
Fix:
- For REST calls with API keys: use
(apiKeySid, apiKeySecret, accountSid). - For webhook validation: use
TWILIO_AUTH_TOKEN. - Rotate compromised credentials; verify environment injection.
3) 20429 — rate limit exceeded
Error text:
Too Many Requests
Rate limit exceeded
Root cause:
- Bursting sends, too many concurrent API calls, or account-level limits.
Fix:
- Implement client-side rate limiting (token bucket).
- Batch sends and use a queue with concurrency control.
- Prefer Messaging Service and distribute across senders if applicable.
4) 30003 — Unreachable destination / carrier violation (often SMS, can surface in mixed services)
Error text:
Message Delivery - Carrier violation
Root cause:
- Carrier filtering, invalid destination, or blocked route.
Fix:
- For WhatsApp, ensure destination is WhatsApp-capable and opted in.
- Verify sender is approved for the destination region.
- Check status callback
ErrorCodeand Twilio Console logs.
5) 63016 — WhatsApp template required / outside session window
Common status callback error message:
63016: Failed to send message because you are outside the allowed window.
Root cause:
- Attempted free-form session message outside 24-hour window.
Fix:
- Use an approved template (Content API) to re-open conversation.
- Track last inbound user message timestamp per user.
6) 63018 — Template not found / not approved / mismatch
Common error:
63018: Content template not found or not approved for use.
Root cause:
- Wrong
contentSid, template not approved, or not enabled for WhatsApp sender.
Fix:
- Verify template approval status in Twilio Console.
- Ensure template is associated with the correct WhatsApp sender / business.
- Deploy template changes before code rollout.
7) 21610 — user opted out (more typical for SMS; still handle suppression uniformly)
Error text:
Attempt to send to unsubscribed recipient
Root cause:
- Recipient opted out (Twilio-managed for SMS) or your own suppression list for WhatsApp.
Fix:
- For WhatsApp: enforce your suppression list before sending.
- Provide a re-subscribe path and record consent.
8) Webhook signature validation failures
Your service logs:
invalid signature
Root cause:
PUBLIC_BASE_URLmismatch (ngrok URL changed), wrong auth token, proxy rewriting URL, missing form parsing.
Fix:
- Ensure the exact URL used in validation matches Twilio’s requested URL (scheme/host/path).
- In Express, use
express.urlencoded({ extended: false })before handler. - If behind a reverse proxy, ensure
PUBLIC_BASE_URLmatches external URL, not internal.
9) Media fetch failures
Status callback may show:
30007: Carrier violation
or Twilio console indicates media fetch error.
Root cause:
- Media URL not publicly accessible, expired presigned URL, blocked by WAF, wrong TLS config.
Fix:
- Presign with sufficient TTL (>= 10 minutes).
- Allow Twilio user agent through WAF or bypass for media bucket.
- Ensure correct
Content-TypeandContent-Length.
10) 11200 — HTTP retrieval failure (webhook endpoint)
Twilio debugger shows:
11200 - HTTP retrieval failure
Root cause:
- Your webhook endpoint timed out, returned 5xx, DNS/TLS issues.
Fix:
- Return 2xx quickly; enqueue work.
- Increase server timeouts; ensure TLS chain is correct.
- Add health checks and autoscaling.
Security Hardening
Webhook validation (mandatory)
- Validate
X-Twilio-Signatureon every inbound and status webhook. - Keep
TWILIO_AUTH_TOKENin a restricted secret store; do not expose to app logs. - If using API keys for REST, still store Auth Token for validation.
Least privilege credentials
- Prefer API Keys over Auth Token for REST calls.
- Rotate API keys quarterly; rotate immediately on suspected compromise.
- Separate keys per environment (dev/stage/prod) and per service.
Transport security
- Enforce TLS 1.2+ on public endpoints.
- Use HSTS on your domain.
- Do not accept plaintext HTTP for webhooks.
Data minimization
- Store only required message content; consider hashing or redacting:
- OTP codes
- Payment details (should never be sent)
- Sensitive PII
- Apply retention policies (e.g., 30–90 days for message bodies, longer for metadata).
Access controls and audit
- Restrict Twilio Console access via SSO and MFA.
- Log all template changes and sender changes.
- Use separate subaccounts for isolation if your org structure supports it.
CIS-aligned host hardening (Linux)
Reference: CIS Ubuntu Linux 22.04 LTS Benchmark (where applicable).
- Run service as non-root user (
whatsapp). - systemd hardening:
NoNewPrivileges=trueProtectSystem=strictProtectHome=truePrivateTmp=true
- File permissions:
/srv/whatsapp/.envmode0600, owned by service user.
- Disable shell access for service user:
/usr/sbin/nologin
WAF / reverse proxy considerations
- Do not IP-allowlist Twilio; validate signatures instead.
- Ensure proxy preserves request body exactly; signature validation is sensitive to parameter changes.
- If you must transform requests, validate at the edge before transformation.
Performance Tuning
1) Webhook latency: enqueue + 204
Target:
- p95 webhook handler latency < 50ms (excluding network)
- Always respond within 1s
Expected impact:
- Reduces Twilio retries and duplicate deliveries.
- Stabilizes under burst traffic.
Implementation:
- Parse + validate signature
- Write minimal event record
- Enqueue job
- Return
204 No Content
2) Outbound throughput: concurrency control
Problem:
- Unbounded concurrency triggers
20429and increases tail latency.
Solution:
- Token bucket per sender or per Messaging Service.
- Start with concurrency 20–50 per pod; tune based on observed 20429 rate.
Expected impact:
- Fewer rate-limit errors; higher sustained throughput.
3) Connection reuse
- Use HTTP keep-alive agent (Node) for Twilio REST calls.
- In Python, reuse client and avoid creating per-request.
Expected impact:
- Lower CPU and latency under high send volume.
4) Dedupe storage
- Use Redis with
SETNXand TTL for webhook dedupe. - Keep TTL aligned with your maximum replay window (7–14 days).
Expected impact:
- Prevents duplicate downstream actions (double replies, double refunds, etc.).
5) Cost optimization via Messaging Service
- Use Messaging Service for sender pooling and routing.
- For mixed channels (SMS/WhatsApp), configure geo-matching and fallback rules carefully.
Expected impact:
- Lower operational overhead; fewer misroutes; potential cost savings depending on routing.
Advanced Topics
Handling out-of-order status transitions
Do not model status as a simple state machine with strict ordering. Instead:
- Store all status events with timestamps.
- Derive “current status” as the max-precedence terminal state:
failed/undeliveredterminal negativereadterminal positivedeliveredpositivesent/queuedtransient
Multi-tenant / subaccount architecture
If you serve multiple customers:
- Use Twilio subaccounts per tenant for isolation.
- Store per-tenant
AccountSidand API key. - Ensure webhook validation uses the correct Auth Token per tenant (map by
Tonumber orAccountSidif provided).
Template localization
- Use Content API with localized variants.
- Choose locale based on user profile; fallback to
en_US. - Keep template variables stable across locales.
Media privacy and compliance
- Presigned URLs leak access if forwarded; keep TTL short.
- Consider proxying media through your domain with auth if policy requires, but ensure Twilio can fetch it.
Disaster recovery
- If webhook processing is down, Twilio will retry for a limited period.
- Persist raw webhook payloads to durable storage (S3/GCS) for replay.
- Provide a replay tool that re-enqueues events by
MessageSid.
Testing strategy
- Unit test:
- Signature validation (known-good fixtures)
- Opt-out keyword parsing
- E.164 normalization
- Integration test:
- Send message to sandbox number
- Verify status callback receipt
- Load test:
- Simulate webhook bursts (e.g., 500 RPS) and ensure 2xx responses
Usage Examples
1) Production: send a template for shipping update, then handle replies
Steps:
- User opts in on website checkout.
- Send template “shipping_update”.
- User replies “Where is my package?”
- Respond with session message.
Node (end-to-end sketch):
// 1) consent stored elsewhere
const to = "+14155550123";
// 2) template send
const templateSid = "HXb5b62575e6e4ff6129ad7c8efe1f983e";
await client.messages.create({
to: `whatsapp:${to}`,
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
contentSid: templateSid,
contentVariables: JSON.stringify({ "1": "Ava", "2": "18473", "3": "UPS" }),
statusCallback: `${process.env.PUBLIC_BASE_URL}/twilio/status`
});
// 3/4) inbound webhook routes to agent/bot and responds within 24h window
2) Media: send invoice PDF with presigned URL
Python:
pdf_url = "https://files.example.com/presigned/invoices/18473.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=..."
sid = client.messages.create(
to="whatsapp:+14155550123",
messaging_service_sid=os.environ["TWILIO_MESSAGING_SERVICE_SID"],
body="Invoice for Order #18473",
media_url=[pdf_url],
status_callback=f"{os.environ['PUBLIC_BASE_URL']}/twilio/status",
).sid
print(sid)
3) Opt-out: user sends STOP, enforce suppression
Inbound handler logic:
- If body is STOP keyword:
- Mark suppressed
- Reply confirmation (session message allowed because user initiated)
Example response (TwiML empty is fine; you can also send outbound message via REST):
<Response></Response>
Then send confirmation via REST:
twilio api:core:messages:create \
--to "whatsapp:+14155550123" \
--messaging-service-sid MG0c3d2e1f4a5b6c7d8e9f0a1b2c3d4e5 \
--body "You are opted out. Reply START to re-subscribe."
4) Status-driven retry: transient failure handling
Policy:
- Do not blindly retry WhatsApp sends on
failedwithout inspecting error code. - Retry only on known transient conditions (e.g., 20429 rate limit) with backoff.
Pseudo:
- If REST call returns 429 or error 20429:
- retry with exponential backoff + jitter
- If status callback returns 63016:
- switch to template message or alternate channel
5) Local dev: ngrok + sandbox
- Start server on port 3000.
- Start ngrok:
ngrok http 3000
- Set
PUBLIC_BASE_URLto ngrok HTTPS URL. - Configure Twilio sandbox inbound webhook to:
https://<id>.ngrok-free.app/twilio/inbound
- Send WhatsApp message to sandbox number; verify inbound handler logs.
6) Multi-region: route by user locale and sender
- Maintain mapping:
country_code -> messaging_service_sid
- Choose Messaging Service based on
Tocountry.
Example mapping file:
Path: /srv/whatsapp/config/routing.yaml
default_messaging_service_sid: MG0c3d2e1f4a5b6c7d8e9f0a1b2c3d4e5
by_country:
US: MG0c3d2e1f4a5b6c7d8e9f0a1b2c3d4e5
GB: YOUR_MG_SID
DE: YOUR_MG_SID
Quick Reference
| Task | Command / API | Key flags/fields |
|---|---|---|
| Send session text | twilio api:core:messages:create |
--to, --messaging-service-sid, --body, --status-callback |
| Send media | messages.create |
media_url[] / --media-url |
| Send template (Content API) | messages.create |
contentSid, contentVariables |
| List messages | twilio api:core:messages:list |
--to, --from, --date-sent, --limit |
| Fetch message | twilio api:core:messages:fetch |
--sid |
| Validate webhook | SDK validator | X-Twilio-Signature, exact URL, TWILIO_AUTH_TOKEN |
| Handle opt-out | inbound parsing | STOP/START keywords + suppression list |
| Diagnose webhook failures | Twilio Console Debugger | error 11200, request/response details |
Graph Relationships
DEPENDS_ON
twilio-core(Twilio REST API fundamentals: auth, subaccounts, API keys)twilio-messaging(Programmable Messaging patterns: Messaging Services, status callbacks)webhook-security(signature validation, replay protection)queueing(Kafka/SQS/PubSub patterns for async processing)secrets-management(KMS, Vault, cloud secret managers)
COMPOSES
twilio-verify(OTP via WhatsApp where supported; fallback strategies)sendgrid-transactional(email fallback when WhatsApp fails or user opted out)twilio-studio(rapid flow prototyping; REST trigger integration)observability(structured logs, tracing, metrics, alerting on failure rates)
SIMILAR_TO
twilio-sms(similar send/status patterns; different compliance and STOP semantics)meta-whatsapp-cloud-api(direct Meta API; Twilio abstracts some concerns but adds its own constraints)twilio-conversations(higher-level conversation orchestration; different primitives than raw Messages API)