twilio-migrate-messaging-to-verify

v2026.09.24

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?".

GitHub
Install command
npx skhub add twilio/twilio-migrate-messaging-to-verify
Markdown
SKILL.md

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 ServiceManaged sender pool (short/long code, toll-free, alpha ID) chosen per-country
random.randint(...) code generationBuilt-in generation (4–10 digits, default 6)
DB table storing code, phone, expires_atStateless — Verify tracks the code and 10-min expiry
Custom resend throttling / attempt countingBuilt-in rate limits (5 sends & 5 checks per number / 10 min)
SMS pumping / fraud filteringFraud Guard (geo-permissions, rate anomaly) — one click
A2P 10DLC campaign registration for the OTP numberExempt — Verify traffic needs no 10DLC campaign
Per-channel integration (SMS, voice, WhatsApp…)One API; change the channel param
Locale-specific message copyPre-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, see twilio-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_SID
    • TWILIO_AUTH_TOKEN
    • VERIFY_SERVICE_SID (created in Step 1 below) — See twilio-iam-auth-setup for 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

friendlyName appears 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 test status === "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=true when 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 approved so 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:

SituationVerify errorHandle by
Malformed / non-E.164 number, bad param60200Fix to format (use Lookup)
Too many sends to a number60203Back off; surface "try again later"
Too many check attempts60202Issue a new verification
Service SID wrong60212Check VERIFY_SERVICE_SID
Country not enabled60410Enable geo-permission in Console
Custom message passed60243Use 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:

  1. Stand up Verify in parallel. Keep the Messaging path live; route a small % of new OTP requests (or one region / internal users) through Verify.
  2. 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.
  3. 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.
  4. 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 customMessage param is deprecated; raw text → error 60243. To customize the copy, use an approved template (TemplateSid) instead.
  • custom_code has 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
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

MIT

Source path

skills/twilio/twilio-migrate-messaging-to-verify

Default branch

main

Latest commit

8aba46f

Tree SHA

958db75