Quickstart: SMS
2FA-by-text is usually the flow nobody automates: it works against somebody's personal phone, or a shared temp-number site that recycles numbers into someone else's test. Paid plans get dedicated US phone numbers instead — provisioned by API, receive-only, with the same wait long-poll and OTP extraction as email.
1 · Install
SDK v0.2.0 or later — phone numbers arrived in 0.2.0.
$ npm install -D mailfixture
$ pip install mailfixture
# no install — curl ships with your OS. # you'll want jq for the examples below.
2 · Authenticate
Same key as email: create one in Dashboard → API keys and export it. The account must be on a paid plan — Free includes no phone numbers (see the fine print).
MAILFIXTURE_API_KEY=mfx_••••••••••••••••••••
3 · Get a number
One call provisions a US local number assigned exclusively to your account until you release it. It may have prior carrier history, as ordinary phone numbers do. Provisioning can return pending; poll the number list until it becomes active before asking your app to send to it. Message wait helpers wait for received texts, but they do not activate a pending carrier order.
import { MailFixture } from 'mailfixture'; const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY async function createActiveNumber() { let 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'); return number; }
import time from mailfixture import MailFixture mf = MailFixture() # reads MAILFIXTURE_API_KEY def create_active_number(): number = mf.create_phone_number() 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") return number
$ curl -X POST https://api.mailfixture.com/v1/phone-numbers \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" → {"id":"7c2e41b0-…","phone_number":"+12015550137","status":"active"} # status may briefly be "pending" — poll GET /v1/phone-numbers until active
4 · Write the test
Point your app's phone-verification field at the number and wait for the code. waitForOtp() is the same server-side long-poll as email — it returns the moment the text lands, or times out. No sleeps, no polling your own phone.
import { test, expect } from '@playwright/test'; import { MailFixture } from 'mailfixture'; const mail = new MailFixture(); test('signup verifies a phone number', async ({ page }) => { const number = await createActiveNumber(); // helper from step 3 try { await page.goto('/signup'); await page.fill('#phone', number.phoneNumber); await page.click('text=Send code'); const otp = await number.waitForOtp({ timeout: 30_000 }); await page.fill('#otp', otp); await expect(page.locator('h1')).toHaveText('Welcome'); } finally { await number.release(); } });
from mailfixture import MailFixture mf = MailFixture() def test_signup_verifies_phone(page): number = create_active_number() # helper from step 3 try: page.goto("/signup") page.fill("#phone", number.phone_number) page.click("text=Send code") otp = number.wait_for_otp(timeout=30) page.fill("#otp", otp) finally: number.release()
# …trigger your app's SMS verification, then long-poll for the text: $ curl "https://api.mailfixture.com/v1/phone-numbers/7c2e41b0-…/messages?wait=45" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" | jq -r .messages[0].id → 0198a3f2-… # summaries include the full text body # the extracted OTP is its own endpoint, same shape as email: $ curl "https://api.mailfixture.com/v1/sms/0198a3f2-…/otp" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" | jq -r .best → 482913
wait_for_otp(match="from:+1415"). SMS match takes from:, to:, and body: prefixes — a bare term searches the body, since texts have no subject. Links get extracted too: GET /v1/sms/:id/links.
5 · Tear down
Unlike inboxes, numbers don't expire on a TTL — they're a plan-capped resource (1 on Solo, 3 on Team, 10 on Scale), so a number per test doesn't scale the way an inbox per test does. Provision once per suite, release when it ends; on Solo the one number is shared state, so serialize the tests that use it.
let number; test.beforeAll(async () => { test.setTimeout(120_000); // the activation poll can outlive the 30s hook default number = await createActiveNumber(); }); test.afterAll(async () => { await number.release(); });
# conftest.py @pytest.fixture(scope="session") def number(mf): number = create_active_number() yield number number.release()
# releasing deletes the number's messages with it $ curl -X DELETE https://api.mailfixture.com/v1/phone-numbers/7c2e41b0-… \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY"
The fine print
Stated plainly, so you don't find out in CI:
— Paid plans only. Free includes no phone numbers. Solo includes 1 number + 100 SMS/mo, Team 3 + 500, Scale 10 + 2,000.
— The SMS quota is a hard stop on every plan. There's no SMS overage billing — texts beyond the monthly quota are dropped. This is different from email, where paid plans bill soft overage.
— US local numbers only at launch. If your app sends codes from a service that only texts non-US numbers, this won't work yet.
— Receive-only, like everything here. We never send SMS. No short codes, no MMS — plain inbound texts to a regular US local number.
— Retention matches email: 14 days on Solo, 30 on Team and Scale. Releasing a number deletes its messages immediately.
— Downgrades don't keep numbers forever. If a plan change leaves you with more numbers than the new plan includes, the extras are scheduled for automatic release after a 7-day grace period — you get an email and a dashboard warning first, and each number's release_after shows the date on the API. Upgrading (or releasing numbers you don't need) before then cancels it. On Free the SMS quota is 0, so inbound texts are already being dropped in the meantime.
Agents
The MCP server carries the same loop for agents: create_phone_number, poll list_phone_numbers until the status is active, trigger the flow, then call wait_for_sms_otp.