OTP API for Playwright: Automate Email Verification Without Regex (2026)

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

page.route() to intercept email

Only works if email is sent client-side; misses SMTP delivery

Mocking sendEmail()

Skips the real email pipeline; hides delivery bugs

Manual OTP entry

Breaks CI entirely

Scraping a web-based temp mail

Requires a second page context; fragile DOM scraping

nodemailer + IMAP

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 automatically

Install 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                              # Windows

Latest release: github.com/DishIs/fce-cli/releases

SDK

bash

npm install freecustom-email      # TypeScript / JavaScript
pip install freecustom-email      # Python

For 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.ts

FCE 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 test

Python: Playwright + FCE SDK

For Python teams using playwright with pytest:

bash

pip install playwright pytest-playwright freecustom-email pytest-asyncio
playwright install chromium

Fixture

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 _get

Test

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 (otp.waitFor)

✓ Auto

FCE CLI (fce otp)

✓ 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.


Written by

D

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.

FAQ

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.

Discussion0

No comments yet. Be the first to share your thoughts.