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 dedicated US local number. It's yours alone — no shared pool, no recycled numbers — until you release it. A number can come back pending for up to about a minute before it turns active; the wait helpers work either way.
import { MailFixture } from 'mailfixture'; const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY const number = await mail.createPhoneNumber(); console.log(number.phoneNumber); // "+12015550137"
from mailfixture import MailFixture mf = MailFixture() # reads MAILFIXTURE_API_KEY number = mf.create_phone_number() print(number.phone_number) # "+12015550137"
$ 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 mail.createPhoneNumber(); 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'); await number.release(); });
from mailfixture import MailFixture mf = MailFixture() def test_signup_verifies_phone(page): number = mf.create_phone_number() 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) 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 () => { number = await mail.createPhoneNumber(); }); test.afterAll(async () => { await number.release(); });
# conftest.py @pytest.fixture(scope="session") def number(mf): number = mf.create_phone_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, then wait_for_sms_otp — "trigger the 2FA flow, read the code" is one tool call each, no glue code.