Disclosure: I work with HeroSMS, the virtual number provider used in the examples below. Written with AI tools; code and endpoints checked against the HeroSMS API reference.
If you've tried to automate OTP in Playwright, you've probably seen this in a nightly run:
✘ [chromium] › tests/signup.spec.ts:4:5 › sign-up with SMS code (30.0s)
Test timeout of 30000ms exceeded.
The root cause: CI runners have no SIM. The usual fixes for a red Playwright OTP test each bite back:
- A shared number with SMS forwarded by an old Android phone that reboots overnight.
- Parallel workers hitting that same number and getting throttled.
- A
SKIP_OTPbackend flag, the most dangerous fix: one bad config and it's live in production.
My previous post solved this in Python by polling a phone-number API. This is the Playwright OTP version in TypeScript: codes arrive by webhook, parallel workers never grab each other's codes, teardown refunds numbers that got no SMS, and it all runs in GitHub Actions.
When a Playwright OTP test needs a real number
| Level | Good for | Risk | Cost |
|---|---|---|---|
| 1. Mocks or the SMS gateway's sandbox | Every PR | Misses integration bugs | Mock upkeep |
| 2. Code from staging DB or Redis | Backends you control | Runners often can't reach the DB | Test-only data layer |
| 3. Real rented number + webhook | Nightly, pre-release, new countries | Carrier delays | A paid number per run |
Fictional numbers belong to level 1: Firebase allows up to 10 "Phone numbers for testing", though Twilio says its test credentials don't work with Verify (see the previous post). Use all three levels, run level 3 on a schedule, and never ship an OTP-bypass flag: CI stays green while proving nothing about SMS.
How the Playwright OTP webhook flow works
Instead of polling, the provider calls you:
fixture -> POST /activations (buy a number)
test -> types it, clicks "Send code"; your app sends the SMS
HeroSMS -> POST sms-incoming -> tunnel -> webhook-server.ts
server -> writes .otp-inbox/<activationId>.json
fixture -> waitForCode() resolves; the test submits the code
teardown -> finish (code used) or DELETE (no code, refund)
Three facts from the API reference drive the design:
- Webhook URLs (HTTPS, up to 3) are set in the account profile, not per request, so every SMS on the account reaches you. If the main account is used for other work, give CI its own account and API key.
- Your endpoint must answer 200 within 3 seconds, or the call is retried (about 7 times, 20–30 seconds apart).
- Requests come from two fixed IPs:
84.32.223.53and185.138.88.87.
So: filter by activationId, always return 200, and run the receiver as its own process. Playwright workers share no memory, so the inbox lives on disk.
Install with npm i -D @playwright/test tsx @types/node (Node 20+).
A minimal HeroSMS client in TypeScript
Auth is an Authorization: ApiKey <token> header; errors are JSON with title, details and sometimes info.
// tests/herosms.ts
const API = 'https://hero-sms.com/api/v1';
export interface Activation {
id: number;
phone: number; // digits only, no "+"
countryPhoneCode: number;
expiredAt: string;
}
export type Offer = { counts: { total: number } };
type ErrorBody = { title?: string; details?: string; info?: Record<string, unknown> };
export class HeroSmsError extends Error {
status: number;
title: string;
info?: Record<string, unknown>;
constructor(status: number, body: ErrorBody) {
super(`HTTP ${status} ${body.title ?? ''}: ${body.details ?? ''}`);
this.status = status;
this.title = body.title ?? '';
this.info = body.info;
}
}
export async function call<T>(method: string, path: string, body?: unknown): Promise<T | undefined> {
const key = process.env.HEROSMS_API_KEY;
if (!key) throw new Error('HEROSMS_API_KEY is not set');
const res = await fetch(API + path, {
method,
headers: { Authorization: `ApiKey ${key}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(15_000), // keep teardown from hanging
});
const text = await res.text();
if (res.status >= 400) {
let err: ErrorBody = {};
try { err = JSON.parse(text) ?? {}; } catch { /* not JSON */ }
throw new HeroSmsError(res.status, err);
}
return text ? (JSON.parse(text) as T) : undefined; // 204 -> undefined
}
export async function checkOffer(service: string, country: number): Promise<Offer | null> {
const qs = new URLSearchParams({ services: service, countries: String(country) });
try {
const res = await call<{ data?: Record<string, Record<string, Offer>> }>('GET', `/activations/offers/sms?${qs}`);
return res?.data?.[service]?.[String(country)] ?? null;
} catch (e) {
if (e instanceof HeroSmsError && e.status === 404) return null; // OFFER_NOT_FOUND
throw e;
}
}
export async function buyNumber(service: string, country: number, maxPrice?: number): Promise<Activation> {
const res = await call<{ data: Activation[] }>('POST', '/activations', {
service, country, amount: 1, verificationType: 'sms', ...(maxPrice ? { maxPrice } : {}),
});
if (!res?.data?.[0]) throw new Error('POST /activations returned no activation');
return res.data[0];
}
export const finishActivation = (id: number) => call('POST', `/activations/${id}/finish`); // no refund
export const cancelActivation = (id: number) => call('DELETE', `/activations/${id}`); // refund
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Never throws: cleanup must not mask the test failure.
export async function release(a: Activation, gotOtp: boolean, boughtAt: number): Promise<void> {
try {
if (gotOtp) {
await finishActivation(a.id);
return;
}
try {
await cancelActivation(a.id);
} catch (e) {
if (!(e instanceof HeroSmsError)) throw e;
if (e.title === 'EARLY_CANCEL_DENIED') {
const minSec = Number(e.info?.minActivationTime) || 120;
await sleep(Math.max(0, boughtAt + minSec * 1000 + 5_000 - Date.now()));
try {
await cancelActivation(a.id);
} catch (e2) {
// The SMS may arrive during the wait: close the activation instead of leaving it open
if (e2 instanceof HeroSmsError && e2.title === 'OTP_RECEIVED') await finishActivation(a.id);
else throw e2;
}
} else if (e.title === 'OTP_RECEIVED') {
await finishActivation(a.id); // late SMS
} else {
throw e;
}
}
} catch (e) {
const what = e instanceof HeroSmsError ? `HTTP ${e.status} ${e.title}` : (e as Error).name;
console.warn(`cleanup ${a.id}: ${what}`);
}
}
-
phoneis an integer without+; the fixture prepends it. With a separate country picker, stripString(a.countryPhoneCode)from the front. -
finishnever refunds.DELETErefunds, but only "if the activation has not received any OTP". On an early-cancel refusal (EARLY_CANCEL_DENIED,minActivationTime: 120seconds in the docs' example),release()waits and retries once; a late SMS (OTP_RECEIVED) getsfinish. It branches ontitle, not HTTP status. - Plain
fetch, not Playwright'srequestfixture, keeps the API key out of traces. - Use service
ot("Any other") and your launch country's ID (16 = United Kingdom).POST /activations/{id}/replaceswaps a rejected number;phoneExceptionexcludes prefixes.
Webhook receiver: one inbox file per activationId
// tests/inbox.ts
import fs from 'node:fs';
import path from 'node:path';
// sms-incoming body (also has phoneFrom, service, country)
export interface SmsWebhookPayload {
activationId: number;
text: string | null;
code?: string | null;
receivedAt: string;
}
const INBOX_DIR = process.env.OTP_INBOX_DIR ?? path.resolve('.otp-inbox');
// sanitize: the id comes from outside
const inboxFile = (id: number | string) =>
path.join(INBOX_DIR, `${String(id).replace(/[^\w-]/g, '_')}.json`);
function readSms(file: string): SmsWebhookPayload | null {
try {
return JSON.parse(fs.readFileSync(file, 'utf8')) as SmsWebhookPayload;
} catch {
return null; // not there yet
}
}
export function saveSms(p: SmsWebhookPayload): void {
fs.mkdirSync(INBOX_DIR, { recursive: true });
const file = inboxFile(p.activationId);
const prev = readSms(file);
if (prev && prev.receivedAt > p.receivedAt) return; // retries repeat, resends are newer
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(p));
fs.renameSync(tmp, file); // atomic: a reader never sees half a file
}
export const extractCode = (p: SmsWebhookPayload) =>
p.code || p.text?.match(/\b\d{4,8}\b/)?.[0];
export function waitForOtp(id: number, timeoutMs: number): Promise<string> {
fs.mkdirSync(INBOX_DIR, { recursive: true });
const file = inboxFile(id);
return new Promise((resolve, reject) => {
const watcher = fs.watch(INBOX_DIR, (_event, name) => {
if (name === path.basename(file)) tryRead();
});
const timer = setTimeout(() => {
watcher.close();
reject(new Error(`No OTP for activation ${id} after ${timeoutMs} ms`));
}, timeoutMs);
function tryRead() {
const sms = readSms(file);
const code = sms ? extractCode(sms) : undefined;
if (!code) return;
watcher.close();
clearTimeout(timer);
resolve(code);
}
tryRead(); // the SMS may already be there
});
}
// tests/webhook-server.ts
import http from 'node:http';
import { saveSms, type SmsWebhookPayload } from './inbox';
const PORT = Number(process.env.WEBHOOK_PORT ?? 3999); // pick a port your app doesn't use
const HOOK_PATH = process.env.WEBHOOK_PATH || '/hooks/herosms'; // use a long random path
const MAX_BODY = 64 * 1024;
http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200).end('ok');
return;
}
if (req.method !== 'POST' || req.url !== HOOK_PATH) {
res.writeHead(404).end();
return;
}
let body = '';
req.setEncoding('utf8'); // non-Latin SMS text can span chunks
req.on('data', (chunk: string) => {
body += chunk;
if (body.length > MAX_BODY) req.destroy();
});
req.on('end', () => {
try {
const p = JSON.parse(body) as SmsWebhookPayload;
if (Number.isFinite(p?.activationId)) saveSms(p);
} catch (err) {
// name only: the body holds SMS text
console.warn('[webhook] bad payload:', err instanceof Error ? err.name : 'unknown');
}
res.writeHead(200, { 'Content-Type': 'application/json' }).end('{}'); // always 200, fast
});
}).listen(PORT, '127.0.0.1');
- Webhook field names differ from REST:
codeandtexthere,smsCodeandsmsTextthere.codecan be null or missing, soextractCodefalls back to a regex ontext; adapt it to your template. - Duplicates and broken payloads still get a fast 200; a non-200 only buys retries. Never log
text, and add.otp-inbox/to.gitignore: each file holds a full SMS. - Enforce the IP allowlist at the tunnel edge, not in Node: behind a tunnel,
remoteAddressis the proxy andX-Forwarded-Forcan be spoofed. The reference documents no webhook signature, so the allowlist plus an unguessableWEBHOOK_PATHare your controls.
The Playwright OTP fixture: buy, wait, always clean up
// tests/otp-fixture.ts
import { test as base, expect } from '@playwright/test';
import { buyNumber, checkOffer, release } from './herosms';
import { waitForOtp } from './inbox';
export type Otp = {
phone: string;
activationId: number;
waitForCode: (timeoutMs?: number) => Promise<string>;
};
export const test = base.extend<{ otp: Otp }>({
otp: async ({}, use) => {
// validate config before spending money
const service = process.env.OTP_SERVICE || 'ot';
const country = Number.parseInt(process.env.OTP_COUNTRY ?? '', 10);
const maxPrice = process.env.HEROSMS_MAX_PRICE ? Number(process.env.HEROSMS_MAX_PRICE) : undefined;
if (!Number.isInteger(country) || Number.isNaN(maxPrice)) {
throw new Error('OTP_COUNTRY must be a country ID, HEROSMS_MAX_PRICE a number');
}
const offer = await checkOffer(service, country); // free, nothing bought yet
if (!offer || offer.counts.total === 0) throw new Error(`No ${service} numbers in country ${country}`);
const a = await buyNumber(service, country, maxPrice);
const boughtAt = Date.now();
let gotOtp = false;
try {
await use({
phone: `+${a.phone}`,
activationId: a.id,
waitForCode: async (ms = 120_000) => {
const code = await waitForOtp(a.id, ms);
gotOtp = true;
return code;
},
});
} finally {
await release(a, gotOtp, boughtAt);
}
},
});
export { expect };
- Code after
use()runs even when the test fails or times out (fixtures docs). - Fixture teardown gets its own timeout, equal to the test timeout, so the early-cancel wait (up to about two minutes) fits.
Writing the Playwright OTP test
// tests/signup.spec.ts
import { test, expect } from './otp-fixture';
test.skip(!process.env.HEROSMS_API_KEY, 'real-SMS test: set HEROSMS_API_KEY');
test('sign-up with a real SMS OTP', { tag: '@real-sms' }, async ({ page, otp }) => {
await page.goto('/signup');
await page.getByLabel('Phone number').fill(otp.phone);
await page.getByRole('button', { name: 'Send code' }).click();
const code = await otp.waitForCode(); // 120 s by default
await page.getByLabel('Verification code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page).toHaveURL(/\/welcome/);
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'tests',
timeout: 240_000, // the OTP wait alone is 120 s
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.APP_URL, // your app, never the webhook receiver
trace: 'retain-on-failure',
},
webServer: {
command: 'npx tsx tests/webhook-server.ts',
url: `http://127.0.0.1:${process.env.WEBHOOK_PORT ?? 3999}/health`,
reuseExistingServer: !process.env.CI,
},
});
- Without the secret, the file-level
test.skipkeeps PR runs from buying anything; the workflow selects it with--grep @real-sms. -
webServerwaits untilurlreturns 2xx, 3xx or 400–403, hence/health. The docs preferurlover the olderport; setuse.baseURLexplicitly. - International SMS can be slow: 120 s for the code, 240 s for the test; keep both well under the activation's
expiredAt. -
--workers=Nis safe as long ascounts.totalcovers N: each worker waits on its ownactivationId.
Running Playwright OTP tests in GitHub Actions
Runners don't accept inbound connections, and the webhook URL is fixed in your account. So reserve an ngrok domain, register https://<domain><WEBHOOK_PATH> once in the HeroSMS profile, and open the tunnel in each job with --url. A traffic policy admits only the two webhook IPs:
# .github/ngrok-policy.yml
on_http_request:
- actions:
- type: restrict-ips
config:
enforce: true
allow:
- 84.32.223.53/32
- 185.138.88.87/32
# .github/workflows/e2e-otp.yml
name: e2e-otp
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * 1-5' # every run buys real numbers
concurrency:
group: e2e-otp
cancel-in-progress: false
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
WEBHOOK_PORT: 3999
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Install ngrok
run: |
curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null
echo "deb https://ngrok-agent.s3.amazonaws.com bookworm main" | sudo tee /etc/apt/sources.list.d/ngrok.list
sudo apt-get update && sudo apt-get install -y ngrok
- name: Start tunnel
env:
NGROK_AUTHTOKEN: ${{ secrets.NGROK_AUTHTOKEN }}
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} # https://<reserved-domain>
run: |
ngrok http "127.0.0.1:$WEBHOOK_PORT" --url "$WEBHOOK_URL" \
--traffic-policy-file .github/ngrok-policy.yml > "$RUNNER_TEMP/ngrok.log" 2>&1 &
for i in $(seq 1 30); do
# policy blocks the runner's IP: 403 = tunnel online
[ "$(curl -s -o /dev/null -w '%{http_code}' "$WEBHOOK_URL/health")" = 403 ] && exit 0
sleep 1
done
cat "$RUNNER_TEMP/ngrok.log"; exit 1
- name: Real-SMS tests
env:
HEROSMS_API_KEY: ${{ secrets.HEROSMS_API_KEY }}
WEBHOOK_PATH: ${{ secrets.WEBHOOK_PATH }}
HEROSMS_MAX_PRICE: ${{ vars.HEROSMS_MAX_PRICE }}
OTP_SERVICE: ${{ vars.OTP_SERVICE }}
OTP_COUNTRY: ${{ vars.OTP_COUNTRY }}
APP_URL: ${{ vars.APP_URL }}
OTP_INBOX_DIR: ${{ runner.temp }}/otp-inbox
run: npx playwright test --grep @real-sms --workers=2
- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-report
path: playwright-report
retention-days: 1 # holds phone numbers and OTPs
- Keys and tokens go in secrets, other settings in
vars.OTP_INBOX_DIRis set on the step because job-levelenvcan't read therunnercontext. -
concurrencymatters: one domain means one tunnel, and you get only 3 webhook URLs. - See the Playwright CI guide for the rest.
- Local dry run:
ngrok http 127.0.0.1:3999 --traffic-policy-file .github/ngrok-policy.ymlwith a randomWEBHOOK_PATH, register the temporary URL plus path, runnpx playwright test, then remove it. - ngrok shows
POST 200but the test times out? Check theactivationId/file-name match, not the network.
Can't open a tunnel (say, a locked-down self-hosted runner)? Poll GET /activations/{id}/otp/last every 5 seconds, treating 400, 404 and empty data as "not yet", as the Python post does.
Responsible use and secrets hygiene
- Test only apps and accounts you own or are authorized to test. Using rented numbers to get around other services' sign-up limits, mass-create accounts or dodge verification typically violates their terms.
- A rented number is yours only for the activation window: don't tie it to anything you'd need to recover, and delete test users afterwards.
- Treat OTPs as secrets.
fill()values end up in traces and HTML reports, so upload reports only on failure, with 1-day retention. Don't print codes; if you must, emit::add-mask::<code>first (logs only, not artifacts). - API key in CI secrets and plain
fetchonly; setHEROSMS_MAX_PRICE. And no OTP-bypass flags, not even "staging only".
Wrap-up
Mocks cover every PR; one scheduled real-number Playwright OTP run catches what they can't: delivery failures, broken SMS templates and per-country quirks. The examples run against HeroSMS; the webhook schema and error titles are in the API reference.
How do you handle OTP in Playwright today: mocks, a test number, or a phone in a drawer?
Top comments (0)