Testing phone verification when a vendor sends the code.
Your app doesn't send verification texts. Twilio Verify does, or MessageBird, or Vonage — your code makes one HTTPS call and the vendor handles templates, sender pools, carrier routing, retries. Which is exactly why "we tested it" so often means "we tested the HTTPS call." This guide is about testing the half you outsourced: proving a real code reaches a real phone number and completes your flow.
The short answer. Provision a dedicated, receive-only US phone number by API, point your signup form at it, and long-poll for the extracted code — no sleeps, no regex, no personal phones. The vendor sends exactly as it does in production; you assert on what actually arrived. Paid plans only, US numbers only, and the monthly SMS quota is a hard stop.
The vendor-integration blind spot
Phone verification through a vendor has a specific failure anatomy, and it's different from the generic "how do I test SMS 2FA" problem (the SMS OTP guide covers that one — personal phones, shared temp-number sites, and why both flake). When a vendor is in the loop, teams reach for one of two things, and both have the same blind spot.
1. The vendor's test mode. Where one exists, it's built for the sending API: test credentials accept your request, magic numbers simulate queued/failed states, and nothing is ever transmitted. That's genuinely useful — it proves your request is well-formed without spending money. But managed verification products are a different surface from raw messaging APIs, and their test-mode coverage is thinner and vendor-specific; check what yours actually simulates before trusting it. Either way, a simulated send proves your request was accepted. It cannot prove a text arrived, because by design nothing did.
2. Stubbing the verification client. Swap the Verify client for a fake in test config, have the fake return a known code, assert the form accepts it. Fast, free, deterministic — and it skips every layer where vendor integrations actually break: the API key that's valid in staging but not prod, the Verify service SID pointing at the wrong service, the phone-number normalization that quietly strips a +1, the template that renders a code your extraction-by-regex can't find, the vendor-side geo restriction nobody remembers configuring. You've proven your code calls a function. It does.
The bugs that page you are in the gap between "the vendor accepted our request" and "a phone received a usable code." Here's a concrete version, because every team has one: signup worked for months, then a routine E.164 normalization refactor started passing numbers like 1201555013 — one digit short, + gone. The stubbed client didn't care; the stub accepts any string. The vendor's API didn't reject it either, at first — it normalized some inputs and failed others, depending on format. The green test suite stayed green while a growing slice of real signups never got their code. The only test that catches this class of bug is one where a real text has to land on a real number.
Which layer tests what
Don't throw the stub away. This is a layering decision, not a replacement:
The third row is the one this guide implements, and the honest scope matters: it exercises your vendor integration end to end against one real US number. It is not a deliverability monitor for every carrier your users are on — that's the vendor's job, and it's why you pay them.
The receiving side, wired up
MailFixture is the receiving half only. It never sends an SMS — your vendor does that, unchanged, exactly as in production. You provision a dedicated US local number over the API, hand it to your signup form, and long-poll for the code. The number is assigned only to your account until you release it. Like any carrier number it may have prior history, and some verification vendors reject virtual or VoIP ranges; if your vendor refuses the number, that is a real compatibility limit rather than something the receiving API can bypass.
Auth is the same mfx_ key as email, from a paid plan (Free includes no phone numbers — the fine print below explains why). Keep it in a CI secret, never in the repo. Numbers are a plan-capped resource, not a per-test one, so provision once per suite and release on exit. In Playwright (JS SDK, npm i -D mailfixture; timeouts are milliseconds):
import { test, expect } from '@playwright/test'; import { MailFixture, PhoneNumber } from 'mailfixture'; const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY let number: PhoneNumber; test.beforeAll(async () => { test.setTimeout(90_000); number = await mail.createPhoneNumber(); const numberId = number.id; const deadline = Date.now() + 60_000; while (number.status !== 'active' && Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 2_000)); const current = (await mail.listPhoneNumbers()).find(n => n.id === numberId); if (!current) throw new Error('provisioned phone number disappeared'); number = current; } if (number.status !== 'active') { throw new Error('phone number did not become active within 60s'); } }); test.afterAll(async () => { if (number) await number.release(); }); test('signup verifies a phone via the vendor', async ({ page }) => { test.setTimeout(60_000); // must exceed the SDK deadline below await page.goto('/signup'); await page.fill('#phone', number.phoneNumber); // exact E.164, e.g. "+12015550137" await page.click('text=Send code'); // your app calls Twilio Verify here const otp = await number.waitForOtp({ timeout: 30_000 }); // resolves when the text lands await page.fill('#otp', otp); await expect(page.locator('h1')).toHaveText('Welcome'); });
The same shape in pytest (pip install mailfixture — timeouts are seconds in Python, not milliseconds):
import pytest import time from mailfixture import MailFixture @pytest.fixture(scope="session") def mf(): return MailFixture() # reads MAILFIXTURE_API_KEY @pytest.fixture(scope="session") def number(mf): number = mf.create_phone_number() try: deadline = time.monotonic() + 60 while number.status != "active" and time.monotonic() < deadline: time.sleep(2) current = next((n for n in mf.list_phone_numbers() if n.id == number.id), None) if current is None: raise RuntimeError("provisioned phone number disappeared") number = current if number.status != "active": raise RuntimeError("phone number did not become active within 60s") yield number finally: number.release() # releasing deletes the number's messages with it
def test_signup_verifies_phone(page, number): page.goto("/signup") page.fill("#phone", number.phone_number) page.click("text=Send code") otp = number.wait_for_otp(timeout=30) # seconds page.fill("#otp", otp)
No SDK for your language? The whole loop is three REST calls:
# provision (status may briefly be "pending" — poll GET /v1/phone-numbers until active) curl -X POST https://api.mailfixture.com/v1/phone-numbers \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" # → {"id": "7c2e41b0-…", "phone_number": "+12015550137", "status": "active", …} # trigger your app's verification flow, then long-poll — the request # returns the moment the text arrives, or after 45s: curl "https://api.mailfixture.com/v1/phone-numbers/7c2e41b0-…/messages?wait=45" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" # the extracted code, ranked candidates included: curl "https://api.mailfixture.com/v1/sms/0198a3f2-…/otp" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" | jq -r .best # → 482913
Two properties do the anti-flake work. The wait is a server-side long-poll — the server holds the request open and answers the instant the text is inserted, so the timeout is a ceiling, not a duration; a code that arrives in 3 seconds unblocks your test in 3 seconds. And the code is a typed extraction, not your regex: every 4–8 digit run in the text is scored by context, so a vendor template like "482913 is your Acme code. Expires in 10 minutes." doesn't trip an assertion on the 10.
Correlating vendor traffic
Vendor senders complicate matching in one specific way: you don't fully control the from side. Verification vendors send from their own number pools, and the sender can differ between runs. Three tools keep assertions precise:
matchfilters the wait:from:,to:, andbody:prefixes, and a bare term searches the body (texts have no subject).wait_for_otp(match="body:Acme")ignores an unrelated text that races in, without hard-coding the vendor's sending number.sinceis a cursor: pass the last-seen timestamp so a resend test waits for the next code, not the one already sitting in the number's history.- Serialization covers what matching can't: two verification codes from the same vendor to the same number look identical. On Solo the single included number is shared state across your whole suite — serialize the tests that use it, the way you'd serialize any singleton. On Team/Scale you can give parallel workers their own numbers, up to the plan cap.
Failure modes
waitForOtp times out; vendor console says "delivered" — your app mangled the recipient (normalization, missing +1) before calling the vendorNumber in the vendor's log ≠ number.phoneNumberAssert your app sends the exact E.164 string you provisionedwaitForOtp)Message timestamp just past the failure timeTest timeout > SDK timeout, always; and remember JS is ms, Python is seconds — timeout=30_000 in Python is 8+ hoursGET /v1/sms/:id/otp shows ranked candidatesAssert on best / the SDK's returned OTP, not your own regex over the bodysince, or spread workers across numbers up to the cappending for up to about a minutestatus: "pending" on the create responseProvision in beforeAll/a session fixture, not inside the first test; the wait helpers work either wayThe fine print, stated plainly
The same limits as any SMS use of the product — better read here than discovered in CI:
- Paid plans only. Real numbers cost real monthly carrier money, so there's no free tier for them. Solo includes 1 number and 100 texts/month, Team 3 and 500, Scale 10 and 2,000.
- The SMS quota is a hard stop on every plan. Unlike email, where paid plans bill soft overage, texts beyond the monthly SMS quota are dropped.
- US local numbers only. If your verification vendor is configured to text only non-US destinations, this won't work for you yet.
- Receive-only, always. MailFixture never sends an SMS — not a reply, not a forward. The number you provision is a standard US local number: no short codes on our side, no MMS.
- Retention follows your plan — 14 days on Solo, 30 on Team and Scale — and releasing a number deletes its messages immediately.
- Downgrades don't strand numbers silently. If a plan change leaves you over the new cap, the extra numbers get a scheduled release date (
release_afteron the API) after a 7-day grace period, with an email and a dashboard warning first.
And the boundary on what a green run proves: your vendor integration delivered a real, extractable, working code to one real US number, end to end. It does not prove delivery to every carrier, handset, or country your users have — carrier-level deliverability is the vendor's product, not something one receiving number can measure.
Test what you own
Read cynically, "receive verification texts programmatically" is also the job description of SMS-verification farming — mass-registering accounts on services the registrant doesn't operate. The terms draw the same line here as for email: registering accounts on services you don't control is prohibited; testing your own product's signup and verification flows is the point, and explicitly carved out. The design backs the words — numbers exist only through authenticated API calls on paid accounts, so every number, every received text, and every abuse report maps to a named, paying account with an audit trail. If what you need is codes for someone else's service, this is deliberately the wrong tool.
Where to go next
- The SMS quickstart gets you from an API key to a green test in a few minutes, in JS, Python, or bare curl: the SMS quickstart.
- If your question is the broader "how do teams test SMS 2FA at all" — personal phones, temp-number sites, provider stubs, and why each flakes: the SMS OTP guide.
- If the verification code arrives by email instead, the flake anatomy and fix: the OTP guide.
And when you're ready to wire it in: create a key, provision a number, and point your vendor at it — the first green run takes about ten minutes.