Testing SMS OTP flows, end to end.
Somewhere in your company there is a phone. It belongs to someone, it holds the number your staging 2FA texts go to, and your "automated" test plan ends with a human reading a code off its lock screen. Email OTP testing got fixed years ago; the SMS version of the same flow is still tested by hand. Here's how teams actually do it, why each way flakes, and the pattern that replaces all three.
How SMS 2FA gets tested today
1. Someone's personal phone. It can't be scripted, so the test is manual forever. It's single-threaded — one number, one code at a time, so parallel runs are out. It doesn't exist in CI at all. And it's attached to a person: the day they're on a plane, the release train waits, because the 2FA phone is somewhere over the Atlantic. A test whose critical dependency takes vacations is not a test.
2. A shared temp-number site. Public "receive SMS online" ranges are commonly rejected by OTP vendors. When delivery does happen, the messages are on a public web page: your codes, everyone's codes, anyone watching. Numbers can also be reused, so the code your assertion matches may belong to someone else's flow.
3. Stubbing the SMS provider in test config. Point the app at a fake sender and assert the fake was called. Tidy, fast, and it proves precisely nothing about delivery: the template rendering, the provider config, the country routing, the phone-number normalization — the exact places 2FA bugs live — never execute. You've tested that your code calls a function. It does.
The anatomy of the fix
Same anatomy as the email version, transposed: a dedicated receive-only number your suite provisions by API, a long-poll the server holds open instead of a sleep, and the OTP returned as a typed field instead of a regex over message text.
The number is assigned exclusively to your account while you hold it, unlike a public inbox anyone can read. That does not guarantee vendor acceptance: ordinary carrier numbers can have prior history, and some services reject virtual/VoIP ranges by policy. Test compatibility with the services your own application is authorized to exercise.
let number; test.beforeAll(async () => { test.setTimeout(120_000); // hooks get the 30s default otherwise — shorter than the loop below number = await mail.createPhoneNumber(); const deadline = Date.now() + 90_000; while (number.status !== 'active' && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 2_000)); number = (await mail.listPhoneNumbers()).find((n) => n.id === number.id) ?? number; } if (number.status !== 'active') throw new Error('phone number did not activate'); }); test.afterAll(async () => { await number.release(); }); test('login verifies by text', async ({ page }) => { await page.goto('/login'); await page.fill('#phone', number.phoneNumber); await page.click('text=Send code'); const otp = await number.waitForOtp({ timeout: 30_000 }); // resolves the moment the text lands await page.fill('#otp', otp); await expect(page.locator('h1')).toHaveText('Welcome'); });
import time import pytest @pytest.fixture(scope="session") def number(mf): number = mf.create_phone_number() # provision once per suite, not per test deadline = time.monotonic() + 90 while number.status != "active" and time.monotonic() < deadline: time.sleep(2) number = next((n for n in mf.list_phone_numbers() if n.id == number.id), number) if number.status != "active": raise RuntimeError("phone number did not activate") yield number number.release() def test_login_verifies_by_text(page, number): page.goto("/login") page.fill("#phone", number.phone_number) page.click("text=Send code") otp = number.wait_for_otp(timeout=30) page.fill("#otp", otp)
The wait is the same server-side long-poll (wait=) the email inboxes use — the request returns the instant the text arrives, and the timeout is a ceiling, not a duration. Extraction is the same engine too: GET /v1/sms/:id/otp returns { best, candidates }, every 4–8 digit run scored by context, so a text that also mentions a year or an order number doesn't fool your assertion the way /\d{6}/ would.
Agents get the identical loop over MCP: create_phone_number, poll list_phone_numbers until it is active, trigger the flow, then call wait_for_sms_otp.
The honest limits
SMS is not email, and the differences are worth knowing before CI finds them for you:
- Paid plans only. Phone numbers cost real monthly money at the carrier, so there's no free-tier version to hand out. Solo includes 1 number and 100 texts a month, Team 3 and 500, Scale 10 and 2,000.
- The monthly SMS quota is a hard stop — on every plan. No overage billing; texts beyond the quota are dropped. Email bills soft overage on paid plans; SMS does not. Size your quota to your suite, not the other way around.
- US local numbers only, for now. No short codes, no MMS — and, like everything here, receive-only: we never send a text.
- Numbers are plan-capped, so provision per suite, not per test. An inbox per test is free; a number per test would exhaust your cap by the third worker. Creation may return
pending, so polllistPhoneNumbers/list_phone_numbersuntil it isactivebefore sending. Release it in teardown. On Solo, the single number is shared state across your suite — serialize the tests that use it. - Retention follows your plan — 14 days on Solo, 30 on Team and Scale. Releasing a number deletes its messages with it.
One boundary worth stating
Read plainly, "receive SMS programmatically" is also the job description of SMS-verification farming — mass-registering accounts on services the registrant doesn't operate. So the line our terms draw for email applies here verbatim: registering accounts on services you don't control is prohibited; testing your own product's signup and 2FA 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 a full audit trail. If what you need is verification codes for someone else's service, this is the wrong tool — on purpose.
Where to go next
The SMS quickstart gets you from a key to a green test in a few minutes, in JS, Python, or bare curl. If a verification vendor — Twilio Verify, MessageBird, Vonage — does your sending, the phone-verification guide covers the vendor-integration blind spot specifically. If your second factor arrives by email instead — or as well — the email OTP guide covers that side's flake anatomy. And if the thing filling in your signup form is an agent rather than a test runner, the MCP quickstart wires the same loop into it.