Twilio SMS and Verify skill for Codex
Registered senders, signed webhooks, no pumping bills. Codex sends Twilio SMS through a Messaging Service on a registered 10DLC or toll-free sender, tracks every message SID through status callbacks, and validates X-Twilio-Signature against the real public URL even behind a proxy. OTPs go through Verify with Fraud Guard and Geo Permissions, and a bundled script counts billable segments and names the characters that force UCS-2.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- twilio-sms Installs to ~/.agents/skills/twilio-sms/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install twilio-sms The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project twilio-sms Installs to .agents/skills/twilio-sms/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/twilio-sms.zipunzip -o twilio-sms.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) twilio-sms PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $twilio-sms, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Twilio messaging and voice features - sending SMS through Messaging Services, status callbacks, inbound webhooks with X-Twilio-Signature validation, TwiML replies and IVR menus, Twilio Verify for OTP codes, A2P 10DLC and toll-free registration, opt-out handling, segment counting and SMS pumping protection. Use when sending SMS or OTP codes from an app, receiving texts or calls on a Twilio number, debugging error 30007, 30034 or 21610, validating Twilio webhooks behind a proxy, or estimating SMS cost per message.
Use it when
- Sending transactional SMS or OTP codes from an application
- Receiving inbound texts or calls on a Twilio number
- Twilio webhook signature validation fails behind a load balancer or tunnel
- SMS costs are higher than expected or messages are being filtered
Use something else when
- You are messaging customers on WhatsApp directly through Meta: use whatsapp-business-api
- You need push notifications to mobile apps: use mobile-push-notifications
How to check the result
Signed test requests pass validation through the deployed proxy, a sent message reaches delivered in the sms log via status callbacks, and every template passes sms_segments.py with the agreed segment limit.
The mistake it prevents
Validating the signature against the internal URL the app server sees instead of the public HTTPS URL Twilio called, so every webhook is rejected in production.
Done well means: messages go through a Messaging Service on a registered sender, every inbound and status webhook is signature-validated, delivery status is stored per message SID, opt-outs are honoured, OTPs use Verify rather than home-made codes, and no SMS or call reaches a real number without the user confirming recipients and text.
Before writing code
- Read
AGENTS.md; find an existing Twilio client, webhook routes and any phone-number model. - Credentials in env vars:
TWILIO_ACCOUNT_SID, plus an API key pair (TWILIO_API_KEY,TWILIO_API_SECRET) for REST calls. Webhook signature validation needsTWILIO_AUTH_TOKEN(API key secrets cannot validate signatures). Never log any of them. - Check sender registration with the user. US traffic on 10-digit long codes requires an approved A2P 10DLC campaign; toll-free numbers require toll-free verification. Unregistered traffic is blocked (error 30034) or filtered.
- Sending to real numbers, bulk campaigns and outbound calls cost money and reach people: confirm recipients, text and volume before running anything that sends.
Calls to Twilio need network access; in a Codex sandbox, ask for approval, or test against mocked clients and the Twilio test credentials described below.
Choose the product
| Need | Use |
|---|---|
| Transactional or notification SMS | Programmable Messaging via a Messaging Service |
| One-time passcodes, phone verification | Twilio Verify (handles code generation, expiry, rate limits, channels) |
| Inbound SMS replies or keyword bots | Messaging webhook returning TwiML or 200 empty |
| Phone menus and call routing | Programmable Voice with TwiML <Gather> |
| WhatsApp through Twilio | Same Messaging API with whatsapp: addresses; template rules from $whatsapp-business-api apply |
Send an SMS
import twilio from "twilio";
const client = twilio(process.env.TWILIO_API_KEY, process.env.TWILIO_API_SECRET, {
accountSid: process.env.TWILIO_ACCOUNT_SID,
});
const msg = await client.messages.create({
to: user.phoneE164, // E.164: +15551234567
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
body: renderTemplate("shipped", order),
statusCallback: `${process.env.PUBLIC_URL}/twilio/status`,
});
await db.smsLog.insert({ sid: msg.sid, to: user.id, status: msg.status });
- Normalise numbers to E.164 at input time (libphonenumber), not at send time.
- A Messaging Service picks the sender, handles sticky sender and opt-outs, and is where 10DLC
campaigns attach. Prefer it over a hard-coded
from. - Store
sid; status callbacks update it throughqueued,sent,delivered,undeliveredorfailed, with anErrorCodeon failure.
Segments and cost
Carriers bill per segment. GSM-7 allows 160 characters in one segment and 153 per segment when split; one non-GSM character (curly quote, em dash, most emoji) switches the whole message to UCS-2, which allows 70 and 67. Check templates before shipping:
python3 scripts/sms_segments.py "Your code is 123456. It expires in 10 minutes."
python3 scripts/sms_segments.py --file templates/sms/shipped.txt --max-segments 2 # exit 1 if over
python3 scripts/sms_segments.py --self-test
The script names the characters that forced UCS-2 so you can replace them (straight quotes, hyphen instead of em dash). Twilio’s Smart Encoding on the Messaging Service can also substitute common look-alikes.
Webhooks and signature validation
// Express: twilio.webhook() validates X-Twilio-Signature using TWILIO_AUTH_TOKEN
app.post("/twilio/sms", express.urlencoded({ extended: false }), twilio.webhook({ protocol: "https" }), (req, res) => {
const { From, Body, MessageSid } = req.body;
const reply = new twilio.twiml.MessagingResponse();
if (/^help$/i.test(Body.trim())) reply.message("Reply STOP to unsubscribe. Support: example.com/help");
res.type("text/xml").send(reply.toString());
});
app.post("/twilio/status", express.urlencoded({ extended: false }), twilio.webhook({ protocol: "https" }), async (req, res) => {
await db.smsLog.update(req.body.MessageSid, { status: req.body.MessageStatus, error: req.body.ErrorCode ?? null });
res.sendStatus(204);
});
Manual validation (Python):
from twilio.request_validator import RequestValidator
ok = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"]).validate(
public_url_exactly_as_configured, request.form.to_dict(), request.headers.get("X-Twilio-Signature", ""))
The signature covers the full public URL Twilio called (scheme, host, port, path, query) plus
POST parameters. Behind a proxy or tunnel the server sees http://internal:3000/..., so
validation fails unless you rebuild the public URL (protocol: 'https', host options, or
X-Forwarded-* handling). Status callbacks can arrive out of order; never move a message
backwards from delivered to sent.
Verify (OTP)
const verify = client.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID!);
await verify.verifications.create({ to: phoneE164, channel: "sms" }); // send code
const check = await verify.verificationChecks.create({ to: phoneE164, code }); // check code
if (check.status !== "approved") throw new Error("Invalid or expired code");
- Rate-limit by user, IP and phone number on your side too; add a CAPTCHA before sending codes from public forms.
- Enable Verify Fraud Guard and restrict Geo Permissions to the countries you serve. SMS pumping (bots requesting codes to premium numbers) is the most common surprise bill.
Voice IVR
app.post("/twilio/voice", express.urlencoded({ extended: false }), twilio.webhook({ protocol: "https" }), (req, res) => {
const vr = new twilio.twiml.VoiceResponse();
const g = vr.gather({ numDigits: 1, action: "/twilio/voice/menu", timeout: 5 });
g.say("Press 1 for order status. Press 2 to speak to someone.");
vr.redirect("/twilio/voice"); // no input: repeat menu
res.type("text/xml").send(vr.toString());
});
Error codes worth mapping
| Code | Meaning | Action |
|---|---|---|
| 21211 | Invalid To number | Fix input validation |
| 21408 | Geo permission not enabled for that country | Enable only if the user approves the region |
| 21610 | Recipient replied STOP | Do not retry; mark opted out |
| 30003 / 30005 | Unreachable or unknown destination | Mark number, retry later at most once |
| 30006 | Landline or unreachable carrier | Offer voice or email |
| 30007 | Carrier filtering (content or volume) | Review content, registration and links |
| 30034 | Unregistered US 10DLC sender | Finish A2P 10DLC registration |
Testing
- Test credentials (test Account SID and auth token from the console) accept magic numbers such
as
From: +15005550006and never send real messages or bill; they cannot be used with Messaging Services or Verify. - For inbound webhooks, expose localhost with a tunnel (
$webhook-testing-tools) and point a dev number at it; never repoint the production number. - Unit-test webhook handlers by signing requests with
twilio.getExpectedTwilioSignature.
Gotchas
- Opt-out keywords (STOP, UNSUBSCRIBE, and others) are handled by Twilio for long codes and toll-free; also record them in your database so other channels respect them.
- Link shorteners from public services trigger filtering; use your own domain or Twilio’s link shortening on a branded domain.
- Quiet hours and local regulations (TCPA in the US, consent rules elsewhere) apply to marketing messages; the user’s counsel should review campaign consent flows.
- Concurrency: Twilio queues messages per sender; bursts above the sender’s throughput are delayed, not dropped, but can expire after the Messaging Service validity period.
Report template
**Sender:** Messaging Service MG..., A2P 10DLC campaign approved (brand + campaign IDs in notes)
**Send path:** messages.create with statusCallback; sms_log(sid, status, error_code)
**Webhooks:** /twilio/sms, /twilio/status, /twilio/voice; signatures validated (proxy-aware URL)
**OTP:** Verify service VA..., Fraud Guard on, Geo Permissions: US, CA
**Templates:** all <= 2 GSM-7 segments (scripts/sms_segments.py in CI)
**Needs user:** live sends, campaign registration details, consent copy review What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Twilio SMS and Verify"
short_description: "Registered senders, signed webhooks, no pumping bills"
default_prompt: "$twilio-sms Send an SMS when an order ships and record whether it was delivered" scripts/sms_segments.py
#!/usr/bin/env python3
"""Count SMS segments for a message the way carriers bill it.
Usage:
python3 sms_segments.py "Your code is 123456"
python3 sms_segments.py --file template.txt [--max-segments 2]
echo "text" | python3 sms_segments.py -
python3 sms_segments.py --self-test
Reports the encoding (GSM-7 or UCS-2), billable characters, segment count, and the characters
that forced UCS-2. Exits 1 when --max-segments is exceeded, so it can gate CI on templates.
"""
import argparse
import sys
GSM_BASIC = set(
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
)
GSM_EXTENDED = set("^{}\\[~]|€\f") # each costs two septets (escape + char)
def analyse(text: str) -> dict:
non_gsm = sorted({c for c in text if c not in GSM_BASIC and c not in GSM_EXTENDED})
if not non_gsm:
units = sum(2 if c in GSM_EXTENDED else 1 for c in text)
single, multi, encoding = 160, 153, "GSM-7"
else:
# UCS-2 counts UTF-16 code units: emoji and other astral characters take two.
units = len(text.encode("utf-16-le")) // 2
single, multi, encoding = 70, 67, "UCS-2"
segments = 0 if units == 0 else (1 if units <= single else -(-units // multi))
return {
"encoding": encoding,
"units": units,
"segments": segments,
"per_segment": single if segments <= 1 else multi,
"non_gsm_chars": non_gsm,
}
def self_test() -> int:
cases = [
("a" * 160, "GSM-7", 1),
("a" * 161, "GSM-7", 2),
("a" * 306, "GSM-7", 2),
("a" * 307, "GSM-7", 3),
("[" * 80, "GSM-7", 1), # 160 septets
("é" * 10, "GSM-7", 1), # é is in the basic GSM set
("ü" + "a" * 69, "GSM-7", 1),
("ú" + "a" * 69, "UCS-2", 1), # ú is not GSM: 70 units, still 1 segment
("ú" + "a" * 70, "UCS-2", 2),
("\U0001F600" * 35, "UCS-2", 1), # 70 UTF-16 units
("", "GSM-7", 0),
]
passed = 0
for text, enc, segs in cases:
r = analyse(text)
ok = r["encoding"] == enc and r["segments"] == segs
passed += ok
if not ok:
print(f"FAIL: {text[:12]!r}... expected {enc}/{segs}, got {r['encoding']}/{r['segments']}")
print(f"self-test: {passed}/{len(cases)} checks passed")
return 0 if passed == len(cases) else 1
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("text", nargs="?", help="message text, or - to read stdin")
p.add_argument("--file", help="read the message from a UTF-8 file")
p.add_argument("--max-segments", type=int, help="exit 1 if the message needs more segments")
p.add_argument("--self-test", action="store_true")
a = p.parse_args()
if a.self_test:
return self_test()
if a.file:
with open(a.file, encoding="utf-8") as fh:
text = fh.read().rstrip("\n")
elif a.text == "-":
text = sys.stdin.read().rstrip("\n")
elif a.text is not None:
text = a.text
else:
p.print_usage(sys.stderr)
return 2
r = analyse(text)
print(f"encoding: {r['encoding']}")
print(f"units: {r['units']} (limit {r['per_segment']} per segment)")
print(f"segments: {r['segments']}")
if r["non_gsm_chars"]:
shown = " ".join(f"{c!r}(U+{ord(c):04X})" for c in r["non_gsm_chars"][:20])
print(f"forced UCS-2 by: {shown}")
if a.max_segments is not None and r["segments"] > a.max_segments:
print(f"FAIL: {r['segments']} segments exceeds limit of {a.max_segments}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.