GUIDE

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. Those free "receive SMS online" numbers are on every fraud blocklist that matters — and the serious OTP senders, the exact services you're testing against, check those lists and quietly refuse to deliver. When delivery does happen, the messages are on a public web page: your codes, everyone's codes, anyone watching. And the numbers recycle — sometimes mid-run — so the code your assertion matches may belong to a stranger's test.

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 dedicated part carries the same weight a custom domain does for email: the number is yours alone, its reputation is whatever your traffic makes it, and no blocklist has a reason to touch it — blocklists exist to catch shared numbers any stranger can read.

2fa.spec.ts · Playwright
let number;

test.beforeAll(async () => { number = await mail.createPhoneNumber(); }); // dedicated US local number
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');
});
conftest.py · pytest
@pytest.fixture(scope="session")
def number(mf):
    number = mf.create_phone_number()  # provision once per suite, not per test
    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, then wait_for_sms_otp — "trigger the 2FA flow, read the code" is one tool call each.

The honest limits

SMS is not email, and the differences are worth knowing before CI finds them for you:

NOTE That fourth point is the one that bites: patterns copied from the email guide assume fixtures are free to mint. Numbers aren't. Suite-scoped, released on exit.

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

dedicated US numbers on every paid plan · from $15/mo
See pricing Read the SMS quickstart