← Back to dashboard
twiliocommsfresh

Twilio Integration Guide

Text
████████╗██╗    ██╗██╗██╗     ██╗ ██████╗
╚══██╔══╝██║    ██║██║██║     ██║██╔═══██╗
   ██║   ██║ █╗ ██║██║██║     ██║██║   ██║
   ██║   ██║███╗██║██║██║     ██║██║   ██║
   ██║   ╚███╔███╔╝██║███████╗██║╚██████╔╝
   ╚═╝    ╚══╝╚══╝ ╚═╝╚══════╝╚═╝ ╚═════╝

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-stack MCP (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

No first-party Twilio MCP server is in the house stack. Drive Twilio from Claude Code via the twilio Node SDK (below) and, optionally, the Twilio CLI. For WhatsApp at scale (templates, message approval, the Cloud API), there is a dedicated whatsapp-business-api guide — 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…).

Bash
npm install twilio
TypeScript
// 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 to ENV_MASTER.md).

2. Send an SMS (Programmable Messaging)

TypeScript
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:

TypeScript
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.

TypeScript
// 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:

TypeScript
// 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" (or max_attempts_reached), not an exception. Only "approved" means verified. After max_attempts_reached you must start a new verification.

4. Voice basics

Outbound calls use TwiML — either a hosted URL that returns TwiML, or inline twiml:

TypeScript
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:

TypeScript
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).

TypeScript
// 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):

TypeScript
// 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:

JavaScript
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.url host differs from the public one, so validation fails with a correct token. Build the URL from x-forwarded-host + https, or set an explicit public URL. Also: express.urlencoded (or formData()) must parse the body before you validate — an unparsed body yields an empty params and 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.

TypeScript
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-api guide. Use Twilio's WhatsApp when you want one provider/billing surface alongside your SMS and Voice.


Environment Variables

Bash
# 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=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Add these to ENV_MASTER.md and each project's .env.example. Validate inbound webhooks with TWILIO_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)

Bash
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 numbers

For 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 CaseApproach
Kenya / East Africa SMSAfrica's Talking (house default) — better local coverage/pricing
US / global SMS, MMSTwilio client.messages.create (E.164 to, Twilio from or Messaging Service)
OTP / phone verificationVerify APIverifications.create then verificationChecks.create (never roll your own)
High-volume / multi-number SMSMessaging Service (messagingServiceSid) + status callback
Outbound voice reminderclient.calls.create with twiml (<Say>)
Inbound SMS / voice IVRWebhook route returning TwiML, signature-validated
WhatsApp (managed)Twilio WhatsApp (whatsapp: prefix); full Business API → whatsapp-business-api guide
Delivery trackingstatusCallback webhook (queued → sent → delivered / undelivered)

Troubleshooting

IssueFix
validateRequest always returns falseSign the exact public URL Twilio called — rebuild from x-forwarded-host + https behind a proxy, not internal req.url
Webhook 403 with correct tokenBody wasn't parsed before validation — run express.urlencoded / req.formData() first so params is populated
Verify "wrong code" not raising an errorA bad code returns status: "pending"/max_attempts_reached, not an exception — only "approved" means verified
max_attempts_reached on checkStart a new verification; the old one is spent
SMS undelivered / errorCode 30007Carrier filtering — register a Messaging Service / A2P 10DLC brand+campaign for US traffic
21610 recipient opted outThe number replied STOP — Twilio blocks until they reply START; respect opt-out
21408/21606 permission or geo errorEnable the destination region (Geo Permissions) or use a from number that supports the channel
Wrong audience / poor delivery in KenyaYou picked Twilio for an East Africa build — switch to Africa's Talking (consult the codeAmani-tech-stack MCP)
WhatsApp message not sent outside 24h windowBusiness-initiated messages need a pre-approved content template; free-form only inside the 24-hour customer window
Auth Token leaked / rotatedRotate in the console; the secondary token lets you roll without downtime — update TWILIO_AUTH_TOKEN everywhere