Twilio migrate messaging to verify
Skill twilio/ai/skills/twilio/twilio-migrate-messaging-to-verify
Migrate an existing OTP / one-time-passcode flow from the Twilio Programmable Messaging API (self-built: generate code → send SMS → validate) to the Twilio Verify API (managed code generation, delivery, expiry, rate limiting, and Fraud Guard). Covers what Verify replaces, creating a Verify Service, replacing send + check logic, E.164 formatting with Lookup, bring-your-own custom codes, error-code translation, and a safe incremental cutover. Use when a developer is moving OTP/2FA delivery off Programmable Messaging onto Verify, or asks "should I use Verify instead of sending my own SMS codes?".From its SKILL.md
npx -y skills add twilio/ai --skill twilio-migrate-messaging-to-verifyAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
13.1 KB, as published. Nobody here has run it
Overview
If you built OTP on the Programmable Messaging API, you are maintaining code that Twilio Verify manages for you: random code generation, a database of codes with expiry, resend/rate-limit logic, and fraud filtering. Verify replaces that entire stack with two API calls — send a verification, check a code — and adds one-click Fraud Guard (SMS pumping protection), managed sender pools, and automatic A2P/compliance handling.
What you delete when you migrate:
| You built (Programmable Messaging) | Verify replaces it with |
|---|---|
| Buy/manage a sending number or Messaging Service | Managed sender pool (short/long code, toll-free, alpha ID) chosen per-country |
random.randint(...) code generation | Built-in generation (4–10 digits, default 6) |
DB table storing code, phone, expires_at | Stateless — Verify tracks the code and 10-min expiry |
| Custom resend throttling / attempt counting | Built-in rate limits (5 sends & 5 checks per number / 10 min) |
| SMS pumping / fraud filtering | Fraud Guard (geo-permissions, rate anomaly) — one click |
| A2P 10DLC campaign registration for the OTP number | Exempt — Verify traffic needs no 10DLC campaign |
| Per-channel integration (SMS, voice, WhatsApp…) | One API; change the channel param |
| Locale-specific message copy | Pre-screened templates in dozens of languages, auto-resolved by country |
This skill focuses on the migration. For full Verify API reference (all channels, service config, status values), see
twilio-verify-send-otp. To decide whether Verify fits before writing code, seetwilio-identity-verification-advisor.
When to stay on Programmable Messaging: you need full control of the message body/branding beyond what templates allow, custom delivery routing, or you are sending non-OTP transactional traffic. For standard OTP/2FA, migrate to Verify.
Prerequisites
- An existing Programmable Messaging OTP implementation (the thing you're replacing)
- Twilio account — New to Twilio? See
twilio-account-setup - Environment variables:
TWILIO_ACCOUNT_SIDTWILIO_AUTH_TOKENVERIFY_SERVICE_SID(created in Step 1 below) — Seetwilio-iam-auth-setupfor credential setup
- SDK:
pip install twilio/npm install twilio(same SDK you already use) - Verify needs no separate product activation — just create a Service
Migration steps
The self-built pattern you're replacing does three things by hand: generate a random code, store it with an expiry, send it over a number you own — then later look up + compare + check expiry yourself. Verify collapses all of that into the two calls below. (The "What you delete" table above maps each piece to its Verify replacement.)
The Verify pattern
Step 1 — Create a Verify Service (one-time)
Do this once and reuse the SID. Create it in the Console or via API:
Python
import os
from twilio.rest import Client
client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
service = client.verify.v2.services.create(friendly_name="Acme") # your business name — shown in the SMS
print(service.sid) # VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx — save in environment variable as VERIFY_SERVICE_SID
Node.js
const twilio = require("twilio");
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
const service = await client.verify.v2.services.create({ friendlyName: "Acme" }); // your business name — shown in the SMS
console.log(service.sid); // VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
friendlyNameappears in the default verification SMS (e.g. "Your Acme verification code is 123456"), so set it to your business name — not an internal label. Avoid embedding 5 or more digits in the name — it can trigger error 60200.
Step 2 — Send the verification (replaces your existing messaging logic)
This single verifications.create call replaces steps you did by hand:
Verify generates the code, tracks it with a 10-min expiry, and delivers it over a
managed sender — so whatever you used to produce the code, persist it, and send
the SMS can all go. A status of pending means the code is on its way.
Python
verification = client.verify.v2 \
.services(os.environ["VERIFY_SERVICE_SID"]) \
.verifications \
.create(to="+15558675310", channel="sms")
print(verification.status) # pending
Node.js
const verification = await client.verify.v2
.services(process.env.VERIFY_SERVICE_SID)
.verifications.create({ to: "+15558675310", channel: "sms" });
console.log(verification.status); // pending
No code generation, no DB write, no owned number. Switch channel by changing
channel to "call", "whatsapp", or "email" (also "sna" and "auto").
RCS isn't a channel value — Verify upgrades an sms verification to RCS where
the device supports it and falls back to SMS automatically.
Step 3 — Check the code (replaces your existing validation logic)
Python
check = client.verify.v2 \
.services(os.environ["VERIFY_SERVICE_SID"]) \
.verification_checks \
.create(to="+15558675310", code="123456")
if check.status == "approved":
grant_access()
else:
reject() # "pending" = wrong/not-yet-submitted, "expired" = past TTL
Node.js
const check = await client.verify.v2
.services(process.env.VERIFY_SERVICE_SID)
.verificationChecks.create({ to: "+15558675310", code: "123456" });
if (check.status === "approved") grantAccess();
else reject();
A wrong code does not throw — it returns
status: "pending". You must teststatus === "approved"explicitly. After approval the verification is single-use; re-checking it returns 404. Delete your OTP database table once traffic is fully on Verify.
Key patterns
Format numbers as E.164 (Verify is strict)
Programmable Messaging tolerated loose formats; Verify requires E.164
(+15558675310). If your DB stores national-format numbers, normalize them
before sending or you'll get error 60200. Use the Lookup API to convert user
input to E.164 and capture the ISO country code — useful for fraud allow/block
lists. See twilio-lookup-phone-intelligence.
Keep your own codes (bring-your-own OTP)
If you can't rip out your existing generation logic on day one, pass your code
via custom_code so Verify still handles delivery, templates, and localization.
Python
verification = client.verify.v2 \
.services(os.environ["VERIFY_SERVICE_SID"]) \
.verifications \
.create(to="+15558675310", channel="sms", custom_code="123456")
Caveats — read before relying on this:
- Enable it per-Service first — it is not on by default. Set
CustomCodeEnabled=truewhen creating/updating the Service via the API (services.update(custom_code_enabled=True)), or toggle "Enable Custom Verification Code" in the Console (Verify → Services → your Service → General). - Do not pass a custom message body. Send only the code via
custom_code; Verify maps it into an approved template. Custom message text returns error 60243. - You are billed per attempted send (not per completed verification), and custom-code flows tend to generate more sends than checks (abandoned/re-requested codes) — expect higher cost than standard Verify.
- Report the outcome so Twilio can keep deliverability high. Verify's route-intelligence engine uses whether a code gets validated as a signal — it automatically retries different carriers/routes when codes go unvalidated or delivery underperforms. Tell Verify the result by updating the verification's status to
approvedso Twilio can keep optimizing routing:
Python
client.verify.v2 \
.services(os.environ["VERIFY_SERVICE_SID"]) \
.verifications(verification.sid) \
.update(status="approved") # after your own code check passes
Treat custom codes as a transition aid, not the end state — migrating to Verify-generated codes removes all these caveats.
Translate your error handling
Verify uses different error codes than Messaging. Map them during migration:
| Situation | Verify error | Handle by |
|---|---|---|
| Malformed / non-E.164 number, bad param | 60200 | Fix to format (use Lookup) |
| Too many sends to a number | 60203 | Back off; surface "try again later" |
| Too many check attempts | 60202 | Issue a new verification |
| Service SID wrong | 60212 | Check VERIFY_SERVICE_SID |
| Country not enabled | 60410 | Enable geo-permission in Console |
| Custom message passed | 60243 | Use custom_code only, not a body |
Full Verify error handling and built-in protections: twilio-verify-send-otp.
Rate limits & fraud — delete your custom throttling
Verify enforces, per phone per service, out of the box: 5 sends / 10 min and
5 check attempts per verification. Codes live 10 minutes and are
immutable within that window (a re-request inside 10 min returns the same
code). This replaces hand-rolled resend counters. Need different limits? Define
custom Rate Limits / Buckets
on the Service. Enable Fraud Guard in the Console to retire your SMS-pumping
defenses. See twilio-security-hardening for the pumping-attack background.
Safe incremental cutover
Don't flip 100% of traffic at once:
- Stand up Verify in parallel. Keep the Messaging path live; route a small % of new OTP requests (or one region / internal users) through Verify.
- Dual-run the check. If a user was issued a legacy code, validate against your DB; if issued via Verify, use
verification_checks. Track which system issued each code so checks hit the right validator. - Drain, then delete. Once no legacy codes remain within their 10-min TTL, stop issuing them, ramp Verify to 100%, then decommission the OTP number/Messaging Service and drop the OTP DB table.
- Reuse one Verify Service across the app — do not create a Service per request.
For testing without tripping rate limits during the cutover, see Twilio's Verify testing docs.
CANNOT
- Cannot retrieve the code Verify generated — never returned by any API (by design for security). If your old flow logged or displayed the code, that stops working.
- Cannot reuse your owned number as the Verify sender by default — Verify picks from managed sender pools per country. (You can bring your own number/Messaging Service for specific channels/WhatsApp, but that's not the default.)
- Cannot pass a raw custom message body — the
customMessageparam is deprecated; raw text → error 60243. To customize the copy, use an approved template (TemplateSid) instead. custom_codehas strings attached — must be enabled per-Service (Console → Verify → Services → General), bills per attempt (higher cost than standard Verify), and mandates updating Verification Status manually.- Cannot change the 10-minute token TTL yourself — to change the default (adjustable between 2 minutes and 24 hours), you must contact Twilio Support to set it on your Service.
- Cannot re-check an approved verification — single-use; a second check returns 404.
- Cannot send to non-E.164 numbers — normalize first (Lookup).
- No push webhook on completion — poll
verification_checks(rate-limited: 60/min, 180/hr, 250/day) or use the Verify Events API. - Cannot skip WhatsApp sender setup — as of March 1, 2024 there is no shared Verify WhatsApp sender; bring your own. See
twilio-whatsapp-manage-senders. - Verify traffic is A2P-exempt, but your remaining non-OTP Messaging is not — keep 10DLC registration for any other traffic on that number.
Next Steps
- Full Verify API reference (all channels, service config):
twilio-verify-send-otp - Decide product fit / architecture first:
twilio-identity-verification-advisor - Normalize numbers to E.164 + fraud signals:
twilio-lookup-phone-intelligence - Bring a WhatsApp sender for WhatsApp OTP:
twilio-whatsapp-manage-senders - Retire SMS-pumping defenses safely:
twilio-security-hardening - Credential setup:
twilio-iam-auth-setup - Source blog post: Migrate from Programmable Messaging to Verify