Ultimate Guide to Email OTP Testing with Playwright
End-to-end Playwright guide for email OTP and magic-link flows in CI — disposable inboxes, expect.poll retries, parallel isolation, and GitHub Actions wiring.
Email OTP and magic-link flows are awkward in Playwright. The browser can fill forms and click buttons, but the verification code lives in an inbox your test runner does not control. Fixed sleep calls flake. Shared Gmail accounts race under parallel workers. Mocking the mailer makes the green build lie about production delivery.
This guide walks through a complete pattern: mint a disposable inbox, drive signup or login in Playwright, poll until the code or link appears, then assert the post-verify UI. Examples use OTPBox because its API is OTP-first and CI-friendly; the same shape works with any inbox API that returns a unique address and a polled code.
Facts below match the live OTPBox API docs and Playwright's expect.poll / waitForURL docs. No invented competitor benchmarks.
What you are building
A reliable E2E path looks like this:
- Create a unique disposable address for this test (or this worker)
- Fill that address into signup, login, or password-reset in the browser
- Trigger the app to send mail (submit the form)
- Poll an API until
otporlinkis present (retry with a deadline — not a single sleep) - Complete the flow: fill the code, or
page.gotothe magic link - Assert the authenticated UI with auto-retrying Playwright expectations
If any of those steps share state across parallel jobs, you get flaky CI. For the failure modes, see 7 Mistakes That Make Email OTP Tests Flake in CI and the CI Email OTP Checklist.
Prerequisites
- Playwright Test (
@playwright/test) with TypeScript or JavaScript - Network access from CI to
https://otpbox.app(or your chosen inbox API) - An app under test that can send a real verification email to an arbitrary address
Local-only SMTP catchers (Mailpit and friends) are fine for laptop Compose setups. For CI runners that should exercise a public SMTP path without managing a sidecar, an OTP-first disposable inbox is usually simpler. Tool tradeoffs: Email OTP Testing Tools for CI.
OTPBox endpoints you will use
From the docs:
| Call | Role | Rate notes |
|---|---|---|
POST /api/inbox |
Create inbox → email, token, expiresAt |
10 creates/hour per IP |
GET /api/inbox/:token |
Full inbox + messages (newest first) | Poll freely |
GET /api/inbox/:token/latest |
Latest otp / link (404 if empty) |
Poll freely |
Product OTP mail lands on *@box.otpbox.app. Inboxes expire after about one hour. Only inbox creation counts toward the free create quota; polling /latest does not. Create once per test, then poll as often as you need.
/latest returns 404 with { "error": "No messages found" } while the inbox is empty. Treat non-OK responses as "not yet," not as a hard failure, until your deadline.
Helper: create inbox + wait for OTP
Put shared helpers next to your tests (for example tests/helpers/otpbox.ts):
type LatestExtraction = {
otp?: string | null;
link?: string | null;
from?: string;
subject?: string;
};
export async function createInbox(): Promise<{ email: string; token: string }> {
const res = await fetch("https://otpbox.app/api/inbox", { method: "POST" });
if (!res.ok) {
throw new Error(`OTPBox create failed: ${res.status}`);
}
const data = (await res.json()) as { email: string; token: string };
return { email: data.email, token: data.token };
}
export async function waitForLatest(
token: string,
{
timeoutMs = 30_000,
intervalMs = 3_000,
}: { timeoutMs?: number; intervalMs?: number } = {}
): Promise<LatestExtraction> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`https://otpbox.app/api/inbox/${token}/latest`);
if (res.ok) {
const data = (await res.json()) as LatestExtraction;
if (data.otp || data.link) return data;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Timeout waiting for OTP/link after ${timeoutMs}ms`);
}
Docs recommend polling about every 3 seconds for up to ~30 seconds (10 attempts) as a starting point. Delivery often lands within a few seconds; keep a generous deadline for CI variance.
Prefer Playwright's expect.poll
If you already use @playwright/test, expect.poll is the idiomatic retry wrapper (Playwright assertions):
import { expect } from "@playwright/test";
export async function waitForOtpWithExpect(token: string): Promise<string> {
const otp = await expect
.poll(
async () => {
const res = await fetch(`https://otpbox.app/api/inbox/${token}/latest`);
if (!res.ok) return null;
const data = (await res.json()) as { otp?: string | null };
return data.otp ?? null;
},
{
message: "waiting for email OTP",
timeout: 30_000,
intervals: [1_000, 2_000, 3_000],
}
)
.not.toBeNull();
return otp as string;
}
expect.poll keeps retrying until the matcher passes or the timeout fires. That is the right tool for "eventual" API state; use locator assertions (toBeVisible, toHaveURL) for UI state.
Full Playwright test: signup + OTP
Selectors below are illustrative — swap labels and roles for your app.
import { test, expect } from "@playwright/test";
import { createInbox, waitForLatest } from "./helpers/otpbox";
test("signup verifies email OTP", async ({ page }) => {
const { email, token } = await createInbox();
// Log the address so a failed CI job can be correlated with send logs
console.log(`OTPBox inbox: ${email}`);
await page.goto("/signup");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill("correct-horse-battery");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText(/check your email|enter the code/i)).toBeVisible();
const { otp } = await waitForLatest(token);
expect(otp).toBeTruthy();
await page.getByLabel("Verification code").fill(otp!);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page).toHaveURL(/\/(dashboard|home|app)/);
await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible();
});
Magic link variant
When the message carries a verification URL instead of (or in addition to) a digit code:
test("magic link login", async ({ page }) => {
const { email, token } = await createInbox();
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByRole("button", { name: "Email me a link" }).click();
const { link } = await waitForLatest(token);
expect(link).toBeTruthy();
await page.goto(link!);
await page.waitForURL("**/app/**");
await expect(page.getByRole("button", { name: /account|profile/i })).toBeVisible();
});
page.waitForURL is the documented way to wait for a post-click navigation when several redirects may fire (Playwright navigations). For deeper magic-link hygiene (no shared inboxes), see Magic Link Testing Without Polluting Real Inboxes.
Parallel workers and isolation
Playwright's default is multiple workers. Each test that creates its own inbox is isolated. Anti-patterns that break under load:
- One shared temp address for the whole suite
- Creating a new inbox inside the poll loop (burns the 10 creates/hour quota)
- Scraping a shared team mailbox for "the latest message"
Create in test (or beforeEach scoped to that file), never once for the whole project unless every worker serializes on that address. FAQ detail: Email OTP Testing FAQ.
GitHub Actions sketch
Wire Playwright after minting an inbox, or let the test create the inbox itself (usually cleaner — secrets stay out of the workflow YAML):
name: E2E
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
BASE_URL: ${{ vars.BASE_URL }}
No OTPBox API key is required for free-tier creates. If your org later uses authenticated higher create limits, pass the key as a secret and send it on POST /api/inbox per your docs — do not hardcode tokens in the repo.
Keep an eye on create volume: 10 creates/hour per IP is plenty for a focused OTP smoke test, but a matrix of dozens of OTP-heavy files on one shared runner IP can hit 429. Split suites or space creates; polling remains unrestricted.
Flaky-test checklist (Playwright-specific)
- Prefer
expect(...)auto-retrying matchers over manualwaitForTimeout - Prefer
expect.pollfor inbox API readiness over a singlesleep - Assert with roles and labels (
getByRole,getByLabel) so refactors hurt less - Log the disposable
emailon failure for support tickets and mail-provider traces - Fail closed on timeout — a missing OTP usually means send failed, not "need a longer sleep"
- Do not parse raw HTML for codes when
/latestalready returnsotp/link
Broader CI pitfalls: How to Test Email OTP Verification in CI.
When not to use a public disposable inbox
- Offline unit tests: inject a fake mailer or in-memory transport
- Local Compose where Mailpit is already the SMTP sink
- Suites that need deep MIME, spam scoring, or SMS in the same tool — consider a dedicated email-test platform
OTPBox is built for the thin E2E path: get the code or link, finish the browser flow, move on.
Related reading
- Email OTP Testing Tools for CI — local SMTP vs email-test APIs vs OTP disposable
- 7 Mistakes That Make Email OTP Tests Flake in CI
- CI Email OTP Checklist
- Email OTP Testing FAQ
Resources
- OTPBox — create a disposable inbox in the browser
- OTPBox API —
POST /api/inbox,GET .../latest - Playwright assertions —
expect.poll, auto-retrying matchers - Playwright navigations —
waitForURL
Email OTP in Playwright is reliable when isolation, polling, and extraction are deliberate. Mint a unique inbox, drive the UI, poll with a deadline, then assert the authenticated state — and leave shared inboxes and fixed sleeps out of CI.