DEV Community

Willette
Willette

Posted on Fully Autonomous

How to Automate OTP in Playwright: Real SMS Codes via Webhook in GitHub Actions

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.
Enter fullscreen mode Exit fullscreen mode

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_OTP backend 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)
Enter fullscreen mode Exit fullscreen mode

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.53 and 185.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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • phone is an integer without +; the fixture prepends it. With a separate country picker, strip String(a.countryPhoneCode) from the front.
  • finish never refunds. DELETE refunds, but only "if the activation has not received any OTP". On an early-cancel refusal (EARLY_CANCEL_DENIED, minActivationTime: 120 seconds in the docs' example), release() waits and retries once; a late SMS (OTP_RECEIVED) gets finish. It branches on title, not HTTP status.
  • Plain fetch, not Playwright's request fixture, keeps the API key out of traces.
  • Use service ot ("Any other") and your launch country's ID (16 = United Kingdom). POST /activations/{id}/replace swaps a rejected number; phoneException excludes 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
  });
}
Enter fullscreen mode Exit fullscreen mode
// 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');
Enter fullscreen mode Exit fullscreen mode
  • Webhook field names differ from REST: code and text here, smsCode and smsText there. code can be null or missing, so extractCode falls back to a regex on text; 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, remoteAddress is the proxy and X-Forwarded-For can be spoofed. The reference documents no webhook signature, so the allowlist plus an unguessable WEBHOOK_PATH are 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 };
Enter fullscreen mode Exit fullscreen mode
  • 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/);
});
Enter fullscreen mode Exit fullscreen mode
// 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,
  },
});
Enter fullscreen mode Exit fullscreen mode
  • Without the secret, the file-level test.skip keeps PR runs from buying anything; the workflow selects it with --grep @real-sms.
  • webServer waits until url returns 2xx, 3xx or 400–403, hence /health. The docs prefer url over the older port; set use.baseURL explicitly.
  • International SMS can be slow: 120 s for the code, 240 s for the test; keep both well under the activation's expiredAt.
  • --workers=N is safe as long as counts.total covers N: each worker waits on its own activationId.

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
Enter fullscreen mode Exit fullscreen mode
# .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
Enter fullscreen mode Exit fullscreen mode
  • Keys and tokens go in secrets, other settings in vars. OTP_INBOX_DIR is set on the step because job-level env can't read the runner context.
  • concurrency matters: 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.yml with a random WEBHOOK_PATH, register the temporary URL plus path, run npx playwright test, then remove it.
  • ngrok shows POST 200 but the test times out? Check the activationId/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 fetch only; set HEROSMS_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)