A signup flow that ends in "check your email for a code" is hard to test end-to-end, because the test needs an inbox that actually exists and actually receives mail. OTPBox gives Playwright a real disposable address and a way to wait for the code it receives — no mocking the email provider, no shared test account that other runs collide on.
Every approach below follows the same four steps: create a real inbox, use its address in the signup form, block until the OTP or verification link arrives, then finish the flow and clean up. The difference is just how much boilerplate you write yourself.
fetchNo dependency beyond Playwright itself. Mint a free key once (200 requests/month, or use an organization key for CI — see Get a key), then call the API directly inside the test:
import { test, expect } from '@playwright/test';
test('sign up with a real OTP', async ({ page }) => {
const key = process.env.OTPBOX_KEY!;
const inboxRes = await fetch('https://otpbox.io/api/v1/inboxes', {
method: 'POST',
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
body: '{}',
});
const inbox = await inboxRes.json(); // { id, address, domain, expiresAt, token }
await page.goto('https://your-app.example.com/signup');
await page.fill('[name="email"]', inbox.address);
await page.click('button[type="submit"]');
// Poll for the code (a real production test should back off / cap retries)
let code: string | null = null;
for (let i = 0; i < 15 && !code; i++) {
await new Promise((r) => setTimeout(r, 1500));
const msgs = await fetch(`https://otpbox.io/api/v1/inboxes/${inbox.id}/messages`, {
headers: { authorization: `Bearer ${key}` },
}).then((r) => r.json());
code = msgs.messages[0]?.code ?? null;
}
await page.fill('[name="otp"]', code!);
await page.click('button[type="submit"]');
await expect(page.locator('text=Welcome')).toBeVisible();
await fetch(`https://otpbox.io/api/v1/inboxes/${inbox.id}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${key}` },
});
});
otpbox-sdk (recommended)otpbox-sdk is the first-party TypeScript client, published on npm, and removes the manual polling loop with waitForOtp():
npm install otpbox-sdk
import { test, expect } from '@playwright/test';
import { OTPBox } from 'otpbox-sdk';
test('sign up with a real OTP', async ({ page }) => {
const client = new OTPBox({ apiKey: process.env.OTPBOX_KEY! });
const inbox = await client.createInbox();
try {
await page.goto('https://your-app.example.com/signup');
await page.fill('[name="email"]', inbox.address);
await page.click('button[type="submit"]');
const code = await client.waitForOtp(inbox.id, { timeoutMs: 20_000 });
await page.fill('[name="otp"]', code!);
await page.click('button[type="submit"]');
await expect(page.locator('text=Welcome')).toBeVisible();
} finally {
await client.deleteInbox(inbox.id);
}
});
waitForOtp() polls getMessages() until a message received after the call started has an extracted code, or the timeout elapses (returns null on timeout — code! above is fine once you've asserted it in your own test, but check for null in real code). There's also waitForEmail() if you want the whole message, and getUsage() if a suite wants to assert it hasn't burned through its quota.
otpbox-playwright fixture (in progress)A dedicated Playwright package, otpbox-playwright, wraps otpbox-sdk in a custom test fixture so a spec doesn't even call createInbox()/deleteInbox() itself:
import { test, expect } from 'otpbox-playwright';
test('sign up with a real OTP', async ({ page, otpboxInbox }) => {
await page.goto('https://your-app.example.com/signup');
await page.fill('[name="email"]', otpboxInbox.address);
await page.click('button[type="submit"]');
const code = await otpboxInbox.waitForOtp();
await page.fill('[name="otp"]', code);
await page.click('button[type="submit"]');
await expect(page.locator('text=Welcome')).toBeVisible();
// no inbox.delete() to write - the fixture tears it down for you
});
otpbox-playwright isn't published to npm yet. It's real, working code in the same monorepo as otpbox-sdk, but until it ships as a package you'd install it via npm link against a clone of the repo rather than npm install otpbox-playwright. Until then, Option 2 (otpbox-sdk directly) is the supported path and gives you the same waitForOtp()/waitForVerificationLink() behavior with a few extra lines of setup/teardown.Some signup flows email a confirmation link rather than a 6-digit code. The same inbox works — use waitForEmail() and read message.link (or, with otpbox-sdk, fetch the full message via getMessage(messageId) for link.url):
const message = await client.waitForEmail(inbox.id, { timeoutMs: 20_000 });
const full = await client.getMessage(message!.id);
await page.goto(full.link!.url);
await expect(page.locator('text=Verified')).toBeVisible();
OTPBOX_KEY as a repo secret, never in the workflow YAML — see the CI/CD section of the docs for GitHub Actions, GitLab CI, Jenkins and CircleCI examples.Idempotency-Key header on POST /api/v1/inboxes if your CI retries flaky network calls, so a retry doesn't create a second inbox — see Idempotency keys.finally block so a failed test doesn't leave it orphaned — though the 15-minute expiry cron cleans it up either way.Ready to try it against your own app? Create a free account
← Back to OTPBox