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