GUIDES / SMS OTP

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.

2fa.spec.ts · Playwright
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');
});
conftest.py · pytest
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:

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

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