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.

  • Integrations
  • $twilio-sms
  • 176 lines
  • 3 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- twilio-sms

Installs to ~/.agents/skills/twilio-sms/ for every project on this machine.

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

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.

~/.agents/skills/twilio-sms/SKILL.md

--- name: twilio-sms description: Builds Twilio messaging and voice features - sending SMS through Messaging Services, status callbacks, inbound webhooks with X-Twilio-Signature… license: MIT ---

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

  1. Read AGENTS.md; find an existing Twilio client, webhook routes and any phone-number model.
  2. Credentials in env vars: TWILIO_ACCOUNT_SID, plus an API key pair (TWILIO_API_KEY, TWILIO_API_SECRET) for REST calls. Webhook signature validation needs TWILIO_AUTH_TOKEN (API key secrets cannot validate signatures). Never log any of them.
  3. 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.
  4. 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

NeedUse
Transactional or notification SMSProgrammable Messaging via a Messaging Service
One-time passcodes, phone verificationTwilio Verify (handles code generation, expiry, rate limits, channels)
Inbound SMS replies or keyword botsMessaging webhook returning TwiML or 200 empty
Phone menus and call routingProgrammable Voice with TwiML <Gather>
WhatsApp through TwilioSame 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 through queued, sent, delivered, undelivered or failed, with an ErrorCode on 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

CodeMeaningAction
21211Invalid To numberFix input validation
21408Geo permission not enabled for that countryEnable only if the user approves the region
21610Recipient replied STOPDo not retry; mark opted out
30003 / 30005Unreachable or unknown destinationMark number, retry later at most once
30006Landline or unreachable carrierOffer voice or email
30007Carrier filtering (content or volume)Review content, registration and links
30034Unregistered US 10DLC senderFinish A2P 10DLC registration

Testing

  • Test credentials (test Account SID and auth token from the console) accept magic numbers such as From: +15005550006 and 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.yamlgenerated from this page

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.py100 lines, 3.7 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem