Twilio Integration Guide
████████╗██╗ ██╗██╗██╗ ██╗ ██████╗
╚══██╔══╝██║ ██║██║██║ ██║██╔═══██╗
██║ ██║ █╗ ██║██║██║ ██║██║ ██║
██║ ██║███╗██║██║██║ ██║██║ ██║
██║ ╚███╔███╔╝██║███████╗██║╚██████╔╝
╚═╝ ╚══╝╚══╝ ╚═╝╚══════╝╚═╝ ╚═════╝Twilio Integration Guide
Focus: When and how codeAmani uses Twilio for US/global communications — Programmable Messaging (SMS/MMS), the managed Verify API for OTP/phone verification, Voice basics, secure inbound webhooks with request-signature validation, and WhatsApp via Twilio. The Kenya default is Africa's Talking; Twilio is the choice for US/global SMS, voice, and OTP (e.g. Florida healthcare clients).
Overview
Twilio is a REST cloud-communications platform with an official twilio Node.js
helper library. Authenticate with your Account SID + Auth Token from
console.twilio.com. The SDK exposes the whole platform —
client.messages (SMS/MMS), client.verify.v2 (OTP/Verify), client.calls (Voice),
plus Conversations, Lookup, and more. SMS and WhatsApp share the same messages.create
call; only the address format changes (+1555… vs whatsapp:+1555…).
Twilio is advertised on motionstackstudios.com for SMS/voice, but it is not the global
default. For the Kenya market the house default is Africa's Talking — better local
network coverage, local short codes/sender IDs, and pricing (see the africas-talking
guide). Twilio earns the slot for US and international reach, Voice, and
managed OTP verification, which is why it backs the Florida healthcare clients.
Here is the canonical OTP flow — the Verify API holds the code, so the app never stores or compares it:
Check first. Before adding Twilio to a build, query the
codeAmani-tech-stackMCP (search_guides/get_guide) and confirm the audience. Kenya / East Africa → Africa's Talking. US / global, or Voice, or managed OTP → Twilio. Picking the wrong provider means worse delivery rates and higher per-message cost.
Official Documentation
| Resource | URL |
|---|---|
| Twilio Docs (root) | https://www.twilio.com/docs |
| Programmable Messaging | https://www.twilio.com/docs/messaging |
| Verify API (OTP) | https://www.twilio.com/docs/verify/api |
| Programmable Voice | https://www.twilio.com/docs/voice |
| Webhooks Security (signature) | https://www.twilio.com/docs/usage/webhooks/webhooks-security |
| WhatsApp via Twilio | https://www.twilio.com/docs/whatsapp |
| Node helper library | https://github.com/twilio/twilio-node |
No first-party Twilio MCP server is in the house stack. Drive Twilio from Claude Code via the
twilioNode SDK (below) and, optionally, the Twilio CLI. For WhatsApp at scale (templates, message approval, the Cloud API), there is a dedicatedwhatsapp-business-apiguide — Twilio's WhatsApp is the simpler managed on-ramp.
1. Credentials & install
Get the Account SID (AC…) and Auth Token from the console dashboard. For Verify,
create a Verify Service in the console and copy its Service SID (VA…). For
Messaging, buy a phone number (or set up a Messaging Service, MG…).
npm install twilio// lib/twilio.ts
import twilio from "twilio";
export const client = twilio(
process.env.TWILIO_ACCOUNT_SID!,
process.env.TWILIO_AUTH_TOKEN!, // server-side only — never ship to the browser
);Phone numbers must be E.164 (
+15619991234,+254711223344). Unlike Africa's Talking, Twilio always wants the leading+. The Auth Token is a full-account secret — keep it server-side (.env.local/ Vercel env, mirror toENV_MASTER.md).
2. Send an SMS (Programmable Messaging)
import { client } from "@/lib/twilio";
export async function sendSms(to: string, body: string) {
const message = await client.messages.create({
to, // E.164, e.g. "+15619991234"
from: process.env.TWILIO_PHONE_NUMBER!, // a Twilio number you own, or use messagingServiceSid
body,
});
return message.sid; // "SM…" — accepted; final state arrives via the status webhook
}For higher deliverability and number pooling, send through a Messaging Service instead
of a single from number, and register a status callback for delivery state:
await client.messages.create({
to: "+15619991234",
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID!, // "MG…"
body: "Your codeAmani appointment is confirmed for Tue 10:00 AM.",
statusCallback: "https://app.example.com/api/twilio/status", // queued → sent → delivered
});MMS: add mediaUrl: ["https://…/file.png"] to attach images/PDFs (US/Canada numbers).
The returned message.status is queued/accepted — that means accepted for sending,
not delivered. Track real delivery via the status callback, and watch for undelivered
/ failed with an errorCode (e.g. 30007 carrier filtering, 21610 recipient opted out).
3. OTP / phone verification — the Verify API (preferred)
Do not roll your own OTP (generating, storing, expiring, and rate-limiting codes is a security footgun). Twilio Verify manages code generation, delivery, expiry, retries, and fraud controls server-side. You only call start and check.
// lib/verify.ts
import { client } from "@/lib/twilio";
const SERVICE = process.env.TWILIO_VERIFY_SERVICE_SID!; // "VA…"
/** Start: send a code over SMS (or "call", "whatsapp", "email"). */
export async function startVerification(to: string) {
const v = await client.verify.v2
.services(SERVICE)
.verifications.create({ to, channel: "sms" });
return v.status; // "pending"
}
/** Check: verify the code the user entered. */
export async function checkVerification(to: string, code: string) {
const check = await client.verify.v2
.services(SERVICE)
.verificationChecks.create({ to, code });
return check.status === "approved"; // else: pending | canceled | max_attempts_reached | failed | expired
}Wire it into a pair of Next.js route handlers — start on submit, check on confirm:
// app/api/verify/start/route.ts
import { NextResponse } from "next/server";
import { startVerification } from "@/lib/verify";
export async function POST(req: Request) {
const { phone } = await req.json();
await startVerification(phone);
return NextResponse.json({ ok: true });
}
// app/api/verify/check/route.ts
import { NextResponse } from "next/server";
import { checkVerification } from "@/lib/verify";
export async function POST(req: Request) {
const { phone, code } = await req.json();
const approved = await checkVerification(phone, code);
if (!approved) return NextResponse.json({ ok: false }, { status: 401 });
// approved → mint your session / mark the phone verified
return NextResponse.json({ ok: true });
}Gotcha: never branch on a thrown error to mean "wrong code." A wrong code returns
status: "pending"(ormax_attempts_reached), not an exception. Only"approved"means verified. Aftermax_attempts_reachedyou must start a new verification.
4. Voice basics
Outbound calls use TwiML — either a hosted URL that returns TwiML, or inline twiml:
const call = await client.calls.create({
to: "+15619991234",
from: process.env.TWILIO_PHONE_NUMBER!,
twiml: "<Response><Say voice=\"Polly.Joanna\">This is a codeAmani appointment reminder.</Say></Response>",
});
return call.sid; // "CA…"For inbound calls and IVR, point your Twilio number's Voice webhook at a route that
returns TwiML built with the SDK's VoiceResponse:
import twilio from "twilio";
const vr = new twilio.twiml.VoiceResponse();
const gather = vr.gather({ numDigits: 1, action: "/api/twilio/voice/handle", method: "POST" });
gather.say("Press 1 for appointments, 2 for billing.");
// res.type("text/xml").send(vr.toString());Verify can also deliver OTP over a call (channel: "call") for users who can't receive SMS.
5. Secure inbound webhooks (signature validation) — required
Every inbound Twilio request (incoming SMS, delivery status, voice events) hits a public
URL, so you must authenticate it. Twilio signs each request and sends the signature in
the X-Twilio-Signature header; validate it with twilio.validateRequest against
the full request URL and the parsed form-urlencoded body. This ties into the
house webhooks guide (verify-then-process, treat the body as untrusted).
// lib/twilio-webhook.ts — framework-agnostic verification
import twilio from "twilio";
export function isValidTwilioRequest(
signature: string,
url: string, // the EXACT public URL Twilio called (incl. https + path + query)
params: Record<string, string>, // parsed application/x-www-form-urlencoded body
): boolean {
return twilio.validateRequest(process.env.TWILIO_AUTH_TOKEN!, signature, url, params);
}Inbound-SMS handler as a Next.js App Router route (Twilio POSTs application/x-www-form-urlencoded):
// app/api/twilio/inbound/route.ts
import { NextResponse } from "next/server";
import { isValidTwilioRequest } from "@/lib/twilio-webhook";
export async function POST(req: Request) {
const signature = req.headers.get("x-twilio-signature") ?? "";
const form = await req.formData();
const params = Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)]));
// The signed URL must match what Twilio called. Behind Vercel/proxies, build it from
// forwarded headers (https + host) — NOT req.url, which may show the internal origin.
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
const url = `https://${host}/api/twilio/inbound`;
if (!isValidTwilioRequest(signature, url, params)) {
return new NextResponse("Invalid signature", { status: 403 });
}
const from = params.From;
const body = params.Body;
// ... process the message (untrusted input) ...
// Reply with TwiML (empty <Response/> = no auto-reply)
return new NextResponse("<Response><Message>Thanks, we got it.</Message></Response>", {
status: 200,
headers: { "Content-Type": "text/xml" },
});
}On Express, the SDK ships a ready-made middleware:
const twilio = require("twilio");
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: false })); // MUST run before validation
app.post(
"/twilio/inbound",
twilio.webhook(), // reads TWILIO_AUTH_TOKEN, validates X-Twilio-Signature, 403s otherwise
(req, res) => {
const reply = new twilio.twiml.MessagingResponse();
reply.message("Thanks, we got it.");
res.type("text/xml").send(reply.toString());
},
);Gotcha — the signed URL must match byte-for-byte. Twilio signs the exact URL it requested (scheme, host, path, and sorted POST params). Behind Vercel/Cloudflare the internal
req.urlhost differs from the public one, so validation fails with a correct token. Build the URL fromx-forwarded-host+https, or set an explicit public URL. Also:express.urlencoded(orformData()) must parse the body before you validate — an unparsed body yields an emptyparamsand a guaranteed mismatch.
6. WhatsApp via Twilio
Twilio is the simplest on-ramp to WhatsApp: the same messages.create, with both numbers
prefixed whatsapp:. Use the Sandbox for
dev; production requires a WhatsApp-enabled sender and pre-approved content templates
for business-initiated (outside the 24-hour window) messages.
await client.messages.create({
to: "whatsapp:+15619991234",
from: "whatsapp:+14155238886", // your WhatsApp sender (Sandbox number in dev)
body: "Your codeAmani appointment is confirmed.",
});For full WhatsApp Business needs — template management, the Meta Cloud API, opt-in flows — see the dedicated
whatsapp-business-apiguide. Use Twilio's WhatsApp when you want one provider/billing surface alongside your SMS and Voice.
Environment Variables
# Account auth (server-side only — the Auth Token is a full-account secret)
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # NEVER ship to the client bundle
# Messaging
TWILIO_PHONE_NUMBER=+15619991234 # a Twilio number you own (E.164)
TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # optional: number pool / sender
# Verify (OTP) — create the Service in the console, copy its SID
TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAdd these to
ENV_MASTER.mdand each project's.env.example. Validate inbound webhooks withTWILIO_AUTH_TOKEN; never expose it to the browser. Prefer per-environment Verify Services so dev/test traffic and rate limits stay isolated from production.
CLI Integration (optional)
npm install -g twilio-cli
twilio login # stores credentials in the keychain
twilio api:core:messages:create \
--from "$TWILIO_PHONE_NUMBER" --to "+15619991234" --body "Hello from codeAmani"
twilio phone-numbers:list # list owned numbersFor local webhook testing, tunnel your dev server (ngrok http 3000) and set the public
HTTPS URL as the number's messaging/voice webhook — the same workflow as the M-Pesa/AT
callback testing in the africas-talking guide.
Common Use Cases
| Use Case | Approach |
|---|---|
| Kenya / East Africa SMS | Africa's Talking (house default) — better local coverage/pricing |
| US / global SMS, MMS | Twilio client.messages.create (E.164 to, Twilio from or Messaging Service) |
| OTP / phone verification | Verify API — verifications.create then verificationChecks.create (never roll your own) |
| High-volume / multi-number SMS | Messaging Service (messagingServiceSid) + status callback |
| Outbound voice reminder | client.calls.create with twiml (<Say>) |
| Inbound SMS / voice IVR | Webhook route returning TwiML, signature-validated |
| WhatsApp (managed) | Twilio WhatsApp (whatsapp: prefix); full Business API → whatsapp-business-api guide |
| Delivery tracking | statusCallback webhook (queued → sent → delivered / undelivered) |
Troubleshooting
| Issue | Fix |
|---|---|
validateRequest always returns false | Sign the exact public URL Twilio called — rebuild from x-forwarded-host + https behind a proxy, not internal req.url |
| Webhook 403 with correct token | Body wasn't parsed before validation — run express.urlencoded / req.formData() first so params is populated |
| Verify "wrong code" not raising an error | A bad code returns status: "pending"/max_attempts_reached, not an exception — only "approved" means verified |
max_attempts_reached on check | Start a new verification; the old one is spent |
SMS undelivered / errorCode 30007 | Carrier filtering — register a Messaging Service / A2P 10DLC brand+campaign for US traffic |
21610 recipient opted out | The number replied STOP — Twilio blocks until they reply START; respect opt-out |
21408/21606 permission or geo error | Enable the destination region (Geo Permissions) or use a from number that supports the channel |
| Wrong audience / poor delivery in Kenya | You picked Twilio for an East Africa build — switch to Africa's Talking (consult the codeAmani-tech-stack MCP) |
| WhatsApp message not sent outside 24h window | Business-initiated messages need a pre-approved content template; free-form only inside the 24-hour customer window |
| Auth Token leaked / rotated | Rotate in the console; the secondary token lets you roll without downtime — update TWILIO_AUTH_TOKEN everywhere |