GUIDES / PHONE VERIFICATION

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:

LAYERWHAT IT PROVESWHAT IT CAN'TWHEN
Stubbed verify clientYour code calls the vendor with the right arguments; the form logic worksAnything past your process boundaryUnit tests, every commit, free
Vendor test credentials / magic numbersThe vendor accepts your request shapeDelivery, template content, extractionIntegration tests, if your vendor's verification product supports it
Real send to a dedicated receiving numberA real code arrived, was extractable, and completed the flowDelivery to arbitrary handsets, carriers, or countriesA small e2e suite: signup, resend, wrong-code, expiry

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):

verify-phone.spec.ts · Playwrightnpm i -D mailfixture
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):

conftest.py
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
test_phone_verification.py
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:

the three calls · any language with an HTTP client
# 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:

Failure modes

SYMPTOM & CAUSEEVIDENCEFIX
Suite green, real users get no codes — verify client stubbed everywhere; no test ever leaves the processThe stub's call log is your only "delivery" recordKeep the stub for unit tests; add the e2e path above for the vendor integration itself
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 provisioned
Timeout, but the dashboard shows the text arrived — after the test failed — test-runner timeout ≤ SDK deadline (Playwright's default 30s equals a 30s waitForOtp)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+ hours
Wrong digits extracted — template contains several digit runs and your code reads the raw textGET /v1/sms/:id/otp shows ranked candidatesAssert on best / the SDK's returned OTP, not your own regex over the body
Every SMS test starts timing out mid-month — monthly SMS quota hit; it's a hard stop, inbound texts beyond it are droppedUsage at cap in the dashboardSize the quota to the suite (100/500/2,000 per month on Solo/Team/Scale) or trim per-run sends; there is no overage billing to absorb it
Parallel tests read each other's codes — one plan-capped number shared across workers; the codes are indistinguishableInterleaved texts in the number's message listSerialize tests that share a number, use since, or spread workers across numbers up to the cap
First test flakes on a fresh number — provisioning can return pending 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 way

The fine print, stated plainly

The same limits as any SMS use of the product — better read here than discovered in CI:

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

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.

dedicated receive-only numbers on paid plans · the vendor sends, you assert
Start free Read the SMS quickstart