
How to Get OTP Codes Into Playwright and Cypress Tests
Any signup flow gated behind an OTP will stall a headless test run the moment Playwright or Cypress hits the wall it can't click through: a six-digit code sitting in an inbox no test runner can see. The usual workaround is stubbing the email step out of the test entirely, which means you're no longer testing the thing that breaks in production the most — the actual verification flow. The fix is simpler than the mock: give each run a real, disposable inbox and poll it for the code the same way a human would.
Why stubbing the OTP step is the wrong call
Mocking email verification feels efficient. You skip the wait, the test runs faster, and the suite goes green. What you lose is coverage of the part of the signup flow that actually fails in production — template regressions, delayed sends, expired-code edge cases, or a copy change that breaks your regex. A test that never touches a real inbox can't catch any of that.
The alternative isn't complicated. It's a disposable inbox per test run, an API call to read it, and a short poll loop waiting for the code to land. That's the whole pattern.
Give every test run its own inbox
Don't reuse one inbox across a test suite. Parallel workers racing to read the same address will grab each other's codes, and a flaky test that fails on inbox contention is worse than no test at all. Generate an address per run, tied to something unique — a CI run ID, a worker index, a timestamp:
[email protected]for CI worker 2 on run 4821signup-local-{randomHex}@cybertemp.xyzfor a developer running the suite locally
There's no separate "create inbox" call to make first. GET /getMail creates the address on its first read if it doesn't exist yet, so generating the string and immediately polling it is enough.
Polling for the code
The API is one endpoint and one header. Read mail with GET /getMail?email=<address>, authenticated with X-API-Key. Most legitimate OTPs land inside a few seconds, so a short poll — every couple seconds, capped around 30 — covers real-world delivery without turning a fast test into a slow one:
async function waitForOtp(email, apiKey, timeoutMs = 30000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const res = await fetch(
`https://api.cybertemp.xyz/getMail?email=${encodeURIComponent(email)}`,
{ headers: { "X-API-Key": apiKey } }
);
const messages = await res.json();
for (const msg of messages) {
const match = /\b(\d{4,8})\b/.exec(msg.text || msg.html || "");
if (match) return match[1];
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`No OTP for ${email} within ${timeoutMs}ms`);
}
That's the whole client. No SDK dependency required for a JavaScript test suite — a fetch call and a regex cover it. If you're building the same pattern in Go, the SDK's WaitForOTP helper does the polling and extraction for you, available on ECO tier and up.
Wiring it into a Playwright fixture
Playwright's fixture system is the natural place for this — generate the address once per test, expose it to the test body, and hand back a helper for grabbing the code after the signup form submits:
import { test as base } from "@playwright/test";export const test = base.extend({ inbox: async ({}, use, testInfo) => { const email =
signup-${testInfo.workerIndex}-${Date.now()}@cybertemp.xyz; await use({ email, waitForOtp: () => waitForOtp(email, process.env.CYBERTEMP_API_KEY), }); }, });
test("signup completes with emailed OTP", async ({ page, inbox }) => { await page.goto("/signup"); await page.fill("#email", inbox.email); await page.click("#send-code"); const code = await inbox.waitForOtp(); await page.fill("#otp", code); await page.click("#verify"); await expect(page.locator("#dashboard")).toBeVisible(); });
Each worker gets its own address by construction, so parallel Playwright runs don't collide on the same inbox.
Wiring it into a Cypress custom command
Cypress commands run in the browser sandbox and can't make an arbitrary cross-origin fetch to api.cybertemp.xyz directly, so the polling logic belongs in a Node task registered in your plugins file, with a thin command wrapping it:
// cypress.config.js on("task", { async waitForOtp(email) { return waitForOtp(email, process.env.CYBERTEMP_API_KEY); }, });// commands.js Cypress.Commands.add("waitForOtp", (email) => cy.task("waitForOtp", email, { timeout: 35000 }) );
// the test cy.visit("/signup"); const email =signup-${Cypress.env("runId")}@cybertemp.xyz; cy.get("#email").type(email); cy.get("#send-code").click(); cy.waitForOtp(email).then((code) => { cy.get("#otp").type(code); cy.get("#verify").click(); });
Bump the Cypress command timeout past your poll window — the default 4-second command timeout will fail the test before the inbox has a chance to receive anything.
Rate limits, parallel workers, and picking a tier
The tier you need depends entirely on how many workers your CI matrix runs at once, not on how big your product is. FREE covers five concurrent inboxes and ten requests a second — fine for a solo suite running sequentially, tight the moment you add parallel workers or a browser matrix. ECO raises that to twenty-five concurrent inboxes and twenty requests a second, and unlocks IMAP access if part of your team wants to inspect a stuck test inbox by hand instead of through the API. CORE goes to a hundred concurrent inboxes with priority throughput, which is where most CI matrices with a handful of parallel jobs across a few browsers land comfortably. ELITE removes the ceiling entirely and adds webhooks, so instead of polling at all, the inbox can push the message to an endpoint the moment it arrives — useful if your test infrastructure already runs a small listener and you'd rather not poll from CI workers.
Whichever tier you're on, delete the inbox with DELETE /inbox?email=<address> at the end of the test run. It's one line in an afterEach or teardown hook, and it keeps a long-running CI account from quietly accumulating thousands of stale test inboxes against its concurrent-inbox limit.
Bottom line
Testing the real OTP flow instead of mocking around it catches the failures that actually reach users — broken templates, slow sends, regex drift on the code format. The pattern is small enough to add to an existing suite in an afternoon: one address per run, one polling function, one cleanup call. Start on FREE to prototype the fixture, then size up to ECO or CORE once you know how many workers your CI matrix actually runs in parallel.
If you're scripting this against a real CI matrix, the paid tiers are where the concurrent-inbox limit stops being something you have to think about, and where webhook delivery on ELITE lets you drop the polling loop entirely. Sign up if that's the suite you're maintaining.
For a one-off manual check of a signup flow, cybertemp.xyz works in the browser too — generate an inbox, paste the address into the form, and watch the code arrive without writing any of the code above.
Related reading: how temporary email protects your privacy covers the broader case for per-service disposable addresses, and the CyberTemp overview walks through the browser workflow this post's fixtures are automating.