skills/twilio/twilio-whatsapp/SKILL.md
WhatsApp Business: template messages, session messages, media, webhooks, opt-in management
npx skillsauth add alphaonedev/openclaw-graph twilio-whatsappInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Enable OpenClaw to implement and operate Twilio WhatsApp Business messaging in production:
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.
whatsapp:+14155238886 or your approved WA number).twilio (Node) 4.23.0twilio (Python) 9.0.5Use one of:
API Key (recommended):
TWILIO_API_KEY_SID (starts with SK...)TWILIO_API_KEY_SECRETTWILIO_ACCOUNT_SID (starts with AC...)Account SID + Auth Token:
TWILIO_ACCOUNT_SIDTWILIO_AUTH_TOKENStore secrets in:
Secret + mounted env vars.env (never commit)Template message (outside 24-hour window):
Session message (inside 24-hour window):
Media message:
MediaUrl (publicly accessible URL) or via Twilio-hosted media in some flows.whatsapp: prefix:
From: whatsapp:+14155238886To: whatsapp:+14155550123MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxSMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxTwo primary webhook categories:
Incoming message webhook (when user sends you a message):
From, To, Body, NumMedia, MediaUrl0, etc.Message status callback (delivery lifecycle):
queued, sent, delivered, read, failed, undelivereddelivered then read, or failed after transient states).MessageSid + MessageStatus + timestamp to dedupe.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
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 [email protected]
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
sudo dnf install -y curl jq nodejs python3 python3-virtualenv
node -v
python3 --version
Twilio CLI:
sudo npm install -g [email protected]
twilio --version
ngrok:
sudo dnf install -y ngrok
ngrok version
Homebrew:
brew update
brew install node@20 [email protected] jq ngrok/ngrok/ngrok
node -v
python3 --version
ngrok version
Twilio CLI:
npm install -g [email protected]
twilio --version
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
Messages API.To and From include whatsapp: prefix.MessagingServiceSid over hardcoding From for 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:
Production recommendation: use Twilio Content API (aka “Content Templates”) when available in your account. This decouples template definition from code and supports localization/variables.
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:
contentVariables keys are strings "1", "2", etc. per Twilio Content variable indexing.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")
Configure statusCallback per message or at Messaging Service level.
Twilio will POST fields including:
MessageSid, MessageStatus, To, From, ErrorCode, ErrorMessageHandler requirements:
(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);
});
Implement:
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:
Twilio requires MediaUrl accessible by Twilio. Common pattern:
Constraints vary; enforce conservative limits:
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 });
}
Store processed webhook IDs:
MessageSidMessageSid + ":" + MessageStatusUse 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
twilio login
Flags:
--profile <name>: store credentials under a named profile--username <AC...>: account SID--password <auth_token>: auth token (interactive if omitted)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 .
twilio api:core:messages:fetch --sid SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Flags:
--sid <SM...>: requiredtwilio 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 variablesExample 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"}'
ngrok http 3000
Copy the HTTPS forwarding URL into:
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
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
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"
Example pipeline:
POST /twilio/inboundtwilio.inbound.v1Kafka 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" }
}
Flow:
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.
63016 (outside window / template required) or user opted out:
Handle Twilio errors at two layers:
ErrorCode/ErrorMessageAt minimum, log:
MessageSid, To, From, MessagingServiceSid, ErrorCode, ErrorMessage, HTTP status, Twilio request ID (X-Twilio-Request-Id if present)Error text (common):
TwilioRestException: The 'To' number +1415555012 is not a valid phone number.
Root cause:
whatsapp: prefix.Fix:
To=whatsapp:+14155550123.Error text:
Authenticate
The AccountSid and AuthToken combination you have provided is invalid.
Root cause:
Fix:
(apiKeySid, apiKeySecret, accountSid).TWILIO_AUTH_TOKEN.Error text:
Too Many Requests
Rate limit exceeded
Root cause:
Fix:
Error text:
Message Delivery - Carrier violation
Root cause:
Fix:
ErrorCode and Twilio Console logs.Common status callback error message:
63016: Failed to send message because you are outside the allowed window.
Root cause:
Fix:
Common error:
63018: Content template not found or not approved for use.
Root cause:
contentSid, template not approved, or not enabled for WhatsApp sender.Fix:
Error text:
Attempt to send to unsubscribed recipient
Root cause:
Fix:
Your service logs:
invalid signature
Root cause:
PUBLIC_BASE_URL mismatch (ngrok URL changed), wrong auth token, proxy rewriting URL, missing form parsing.Fix:
express.urlencoded({ extended: false }) before handler.PUBLIC_BASE_URL matches external URL, not internal.Status callback may show:
30007: Carrier violation
or Twilio console indicates media fetch error.
Root cause:
Fix:
Content-Type and Content-Length.Twilio debugger shows:
11200 - HTTP retrieval failure
Root cause:
Fix:
X-Twilio-Signature on every inbound and status webhook.TWILIO_AUTH_TOKEN in a restricted secret store; do not expose to app logs.Reference: CIS Ubuntu Linux 22.04 LTS Benchmark (where applicable).
whatsapp).NoNewPrivileges=trueProtectSystem=strictProtectHome=truePrivateTmp=true/srv/whatsapp/.env mode 0600, owned by service user./usr/sbin/nologinTarget:
Expected impact:
Implementation:
204 No ContentProblem:
20429 and increases tail latency.Solution:
Expected impact:
Expected impact:
SETNX and TTL for webhook dedupe.Expected impact:
Expected impact:
Do not model status as a simple state machine with strict ordering. Instead:
failed/undelivered terminal negativeread terminal positivedelivered positivesent/queued transientIf you serve multiple customers:
AccountSid and API key.To number or AccountSid if provided).en_US.MessageSid.Steps:
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
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)
Inbound handler logic:
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."
Policy:
failed without inspecting error code.Pseudo:
ngrok http 3000
PUBLIC_BASE_URL to ngrok HTTPS URL.https://<id>.ngrok-free.app/twilio/inboundcountry_code -> messaging_service_sidTo country.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
| 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 |
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)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)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)tools
Root web development: project structure, tooling selection, deployment decisions
development
WebAssembly: Rust/Go/C to WASM, wasm-bindgen, Emscripten, WASM Component Model
development
Vue 3: Composition API script setup, Pinia, Vue Router 4, SFCs, Vite, Nuxt 3
tools
Tailwind CSS 4: utility classes, config, JIT, arbitrary values, darkMode, plugins, shadcn/ui