---
name: twilio-migrate-messaging-to-verify
description: >
  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?".
---

## 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, 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](https://1console.twilio.com/go?to=/account/__account__/us1/verify/services) or via API:

**Python**
```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**
```node
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**
```python
verification = client.verify.v2 \
    .services(os.environ["VERIFY_SERVICE_SID"]) \
    .verifications \
    .create(to="+15558675310", channel="sms")

print(verification.status)  # pending
```

**Node.js**
```node
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**
```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**
```node
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**
```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](https://www.twilio.com/docs/api/errors/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**
```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](https://www.twilio.com/docs/verify/api/service-rate-limits)
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](https://www.twilio.com/docs/verify/quickstarts/test).

---

## 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](https://www.twilio.com/en-us/blog/migrate-programmable-messaging-to-verify)
