Playwright has rapidly become the go-to end-to-end testing framework for modern web apps. Its auto-waiting, parallel execution model, and first-class TypeScript support make it genuinely pleasant to use — right up until your app sends a verification email and your test suite has nowhere to receive it.
This post covers exactly how to solve that with FreeCustom.Email, which provides a disposable inbox API with a dedicated OTP extraction endpoint, real-time WebSocket delivery under 200ms, official SDKs for TypeScript and Python, and the only official CLI in the entire disposable email space.
The Problem with OTP Testing in Playwright
Most Playwright test guides gloss over email verification. The common workarounds are:
Workaround | Problem |
|---|---|
Hard-coded test inbox | Race conditions in parallel workers; stale OTPs from previous runs |
| Only works if email is sent client-side; misses SMTP delivery |
Mocking | Skips the real email pipeline; hides delivery bugs |
Manual OTP entry | Breaks CI entirely |
Scraping a web-based temp mail | Requires a second |
| Complex auth setup; OAuth rotation; no OTP parsing |
The only approach that works reliably at scale: a purpose-built OTP API that gives each Playwright worker its own isolated inbox.
Setup
Account and CLI
Register at freecustom.email and run:
bash
fce login
# Browser opens → sign in → API key saved to OS keychain automaticallyInstall the CLI for local workflows (all install options):
bash
curl -fsSL freecustom.email/install.sh | sh # macOS / Linux
npm install -g fcemail@latest # All platforms
choco install fce # WindowsLatest release: github.com/DishIs/fce-cli/releases
SDK
bash
npm install freecustom-email # TypeScript / JavaScript
pip install freecustom-email # PythonFor CI runners, get your key from the FCE dashboard and set FCE_API_KEY as a secret.
TypeScript: Playwright + FCE SDK
Project structure
tests/
fixtures/
fce.fixture.ts ← creates/destroys inboxes per test
signup.spec.ts
reset-password.spec.ts
playwright.config.tsFCE fixture
The cleanest pattern in Playwright is a custom fixture. It creates a fresh inbox before each test and destroys it after — no manual setup or teardown in test bodies.
typescript
// tests/fixtures/fce.fixture.ts
import { test as base } from '@playwright/test';
import { FreecustomEmailClient } from 'freecustom-email';
type FceFixture = {
fce: FreecustomEmailClient;
inbox: string;
getOtp: (timeoutMs?: number) => Promise<string>;
};
const fce = new FreecustomEmailClient({ apiKey: process.env.FCE_API_KEY! });
export const test = base.extend<FceFixture>({
fce: async ({}, use) => {
await use(fce);
},
inbox: async ({}, use) => {
// Each Playwright worker gets a unique inbox — guaranteed isolation
const addr = `pw-${Date.now()}-${Math.random().toString(36).slice(2, 7)}@ditmail.info`;
await fce.inboxes.register(addr);
await use(addr);
await fce.inboxes.unregister(addr).catch(() => {});
},
getOtp: async ({ inbox }, use) => {
const fn = (timeoutMs = 30_000) => fce.otp.waitFor(inbox, { timeout: timeoutMs });
await use(fn);
},
});
export { expect } from '@playwright/test';Signup test
typescript
// tests/signup.spec.ts
import { test, expect } from './fixtures/fce.fixture';
test('user can sign up and verify email', async ({ page, inbox, getOtp }) => {
// Navigate to signup
await page.goto('/signup');
// Fill form with the temp inbox address
await page.fill('#email', inbox);
await page.fill('#password', 'TestPass123!');
await page.click('button[type="submit"]');
// Wait for verification screen
await page.waitForSelector('#otp-input');
// Extract OTP — no regex, no polling loop, no second browser
const otp = await getOtp(30_000);
expect(otp).toMatch(/^\d+$/);
// Enter OTP and complete verification
await page.fill('#otp-input', otp);
await page.click('button[type="submit"]');
// Assert success
await page.waitForURL('**/dashboard');
expect(page.url()).toContain('/dashboard');
});Password reset test
typescript
// tests/reset-password.spec.ts
import { test, expect } from './fixtures/fce.fixture';
test('user can reset password via email OTP', async ({ page, inbox, getOtp }) => {
// Create and verify an account first
await page.goto('/signup');
await page.fill('#email', inbox);
await page.fill('#password', 'OriginalPass1!');
await page.click('button[type="submit"]');
await page.waitForSelector('#otp-input');
await page.fill('#otp-input', await getOtp());
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
// Log out and request reset
await page.goto('/logout');
await page.goto('/forgot-password');
await page.fill('#email', inbox);
await page.click('button[type="submit"]');
await page.waitForSelector('#reset-otp');
// getOtp always returns the latest — no confusion with old codes
const resetOtp = await getOtp();
await page.fill('#reset-otp', resetOtp);
await page.fill('#new-password', 'NewPass456!');
await page.click('button[type="submit"]');
await page.waitForURL('**/login');
});playwright.config.ts — parallel workers
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
workers: 4, // 4 parallel workers, each gets its own inbox
retries: 1,
timeout: 60_000,
use: {
baseURL: process.env.APP_URL,
headless: true,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});Run tests:
bash
FCE_API_KEY=fce_... npx playwright testPython: Playwright + FCE SDK
For Python teams using playwright with pytest:
bash
pip install playwright pytest-playwright freecustom-email pytest-asyncio
playwright install chromiumFixture
python
# conftest.py
import asyncio
import os
import time
import pytest
from freecustom_email import FreeCustomEmail
fce = FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])
@pytest.fixture
def inbox():
addr = f"pw-{int(time.time())}@ditmail.info"
asyncio.run(fce.inboxes.register(addr))
yield addr
asyncio.run(fce.inboxes.unregister(addr))
@pytest.fixture
def get_otp(inbox):
def _get(timeout=30):
return asyncio.run(fce.otp.wait_for(inbox, timeout=timeout))
return _getTest
python
# tests/test_signup_playwright.py
from playwright.sync_api import Page, expect
def test_signup_and_verify(page: Page, inbox, get_otp):
page.goto("/signup")
page.fill("#email", inbox)
page.fill("#password", "TestPass123!")
page.click("button[type='submit']")
page.wait_for_selector("#otp-input")
otp = get_otp(timeout=30)
assert otp and otp.isdigit(), f"Invalid OTP: {otp!r}"
page.fill("#otp-input", otp)
page.click("button[type='submit']")
page.wait_for_url("**/dashboard")
expect(page).to_have_url(re.compile(r"/dashboard"))Using fce otp CLI in Playwright Shell Scripts
If you use Playwright via the CLI (npx playwright test --reporter=list) or in a shell script, you can call fce otp directly instead of the SDK:
bash
#!/usr/bin/env bash
set -euo pipefail
# Create inbox
INBOX=$(fce inbox add random | tr -d '[:space:]')
export PLAYWRIGHT_TEST_INBOX="$INBOX"
export PLAYWRIGHT_TEST_OTP_CMD="fce otp $INBOX | grep 'OTP ·' | awk '{print \$NF}'"
# Run Playwright with the inbox injected via env
npx playwright test tests/signup.spec.ts
# Get OTP in a shell step if needed
OTP=$(fce otp "$INBOX" | grep "OTP ·" | awk '{print $NF}')
echo "OTP was: $OTP"
fce inbox remove "$INBOX"For full CLI usage, see the fce CLI documentation.
Using WebSocket for Instant Delivery (Startup plan+)
The default otp.waitFor() SDK method polls the API. On Startup plan+, you can use the WebSocket layer for instant push delivery — useful when your tests run against a fast staging environment and you want zero wait:
typescript
// TypeScript — WebSocket-based OTP watching
import { FreecustomEmailClient } from 'freecustom-email';
const fce = new FreecustomEmailClient({ apiKey: process.env.FCE_API_KEY! });
async function waitForOtpRealtime(inbox: string): Promise<string> {
return new Promise((resolve, reject) => {
const ws = fce.realtime.connect(inbox);
const timeout = setTimeout(() => {
ws.disconnect();
reject(new Error('OTP timeout'));
}, 30_000);
ws.on('email', (email) => {
if (email.otp) {
clearTimeout(timeout);
ws.disconnect();
resolve(email.otp);
}
});
});
}GitHub Actions: Playwright E2E with FCE
yaml
# .github/workflows/playwright.yml
name: Playwright E2E
on: [push, pull_request]
jobs:
playwright:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
env:
FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
APP_URL: ${{ vars.STAGING_URL }}
run: npx playwright test --workers=4
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/Playwright Test: Custom Domain to Bypass Email Blockers
Some apps reject known disposable domains at the form level. Use a custom domain on Growth plan:
typescript
// Instead of @ditmail.info, use your own domain
const inbox = `pw-test-${Date.now()}@tests.yourcompany.com`;
await fce.inboxes.register(inbox);Register and verify your domain in the FCE dashboard. After that, POST /v1/inboxes accepts any address at your verified domain.
Related: Private or Custom Domains: How to Get Your Own Custom Temporary Email
Complete Feature and Plan Reference
Feature | Free | Developer | Startup | Growth |
|---|---|---|---|---|
Create inboxes | ✓ | ✓ | ✓ | ✓ |
Fetch messages (polling) | ✓ | ✓ | ✓ | ✓ |
WebSocket real-time stream | ✗ | ✗ | ✓ | ✓ |
OTP extraction API | ✗ | ✗ | ✗ | ✓ |
Custom domains | ✗ | ✗ | ✗ | ✓ |
Req/month | 5,000 | 100,000 | 500,000 | 2,000,000 |
Price | $0 | $7/mo | $19/mo | $49/mo |
Full pricing at freecustom.email/api/pricing.
Comparing OTP Approaches in Playwright
Method | OTP extraction | Parallel safe | CI-ready | Zero regex |
|---|---|---|---|---|
FCE SDK ( | ✓ Auto | ✓ | ✓ | ✓ |
FCE CLI ( | ✓ Auto | ✓ | ✓ | ✓ |
FCE REST API (polling) | ✓ Manual parse | ✓ | ✓ | Partial |
Mailinator API | ✗ (manual regex) | ✓ | Partial | ✗ |
Gmail IMAP | ✗ (manual parse) | ✗ | ✗ | ✗ |
Page scrape on temp mail site | ✗ (DOM scraping) | ✗ | ✗ | ✗ |
Frequently Asked Questions
Can I use FCE with Playwright's built-in page.waitForResponse()? Yes — if your app has an endpoint that confirms the email was sent, you can use waitForResponse to know when to call getOtp(). It is a useful signal to avoid polling before the email even exists.
Does FCE work with Playwright's component testing? Component tests do not involve a real SMTP stack. FCE is relevant for full E2E tests where the browser interacts with a real backend that sends real emails.
How do I handle a magic link instead of a numeric OTP? The FCE SDK returns verification_link on the email object when a clickable link is present. Use await page.goto(email.verification_link) instead of filling an OTP field.
Can I use the same inbox across multiple test.step() calls? Yes — the inbox persists for the lifetime of the fixture (the test). Multiple steps in the same test can call getOtp() multiple times; each call returns the latest OTP received.
What if Playwright retries a failed test — does it get a fresh inbox? With the fixture approach above, yes. Each retry is a new test execution, which triggers the fixture's setup again, creating a fresh inbox. This is one of the key advantages of the fixture pattern over global setup.
Is there a Playwright-specific SDK or plugin? No dedicated plugin is needed — the freecustom-email npm package integrates cleanly via the custom fixture pattern shown above.
How many parallel Playwright workers can I run? As many as your plan's req/sec allows. Growth (50 req/sec) supports 50 inbox creations per second. For very large suites, Enterprise (100 req/sec) gives headroom for hundreds of parallel workers.
Related reading
Written by
Dishant Singh
A full stack developer with good knowledge of email server, SEO, proxies, and networking, have more than 3 years of experience in building webapps for the netizens. Developing open source, fast, and free SaaS for all.
Frequently Asked Questions
Does FCE work with Playwright's component testing?+
Component tests do not involve a real SMTP stack. FCE is relevant for full E2E tests where the browser interacts with a real backend that sends real emails.
How do I handle a magic link instead of a numeric OTP?+
The FCE SDK returns verification_link on the email object when a clickable link is present. Use await page.goto(email.verification_link) instead of filling an OTP field.
What if Playwright retries a failed test — does it get a fresh inbox?+
With the fixture approach above, yes. Each retry is a new test execution, which triggers the fixture's setup again, creating a fresh inbox. This is one of the key advantages of the fixture pattern over global setup.
Is there a Playwright-specific SDK or plugin?+
No dedicated plugin is needed — the freecustom-email npm package integrates cleanly via the custom fixture pattern shown above.
How many parallel Playwright workers can I run?+
As many as your plan's req/sec allows. Growth (50 req/sec) supports 50 inbox creations per second. For very large suites, Enterprise (100 req/sec) gives headroom for hundreds of parallel workers.
No comments yet. Be the first to share your thoughts.