Email OTP Tests in Playwright and Cypress

The hardest step in many end-to-end tests is the email code. The test fills in a sign-up form, the app emails a code, and the test has no way to read it. This guide closes that gap in Playwright and Cypress. The test rents a real @gmail.com or @icloud.com inbox through the OTPGmail API. It types the address into the form, then polls the API until the code arrives. If no code comes, the test cancels the order and the money returns to your balance.

When a real inbox is the right tool

A real inbox makes sense when the test must go through real email delivery:

For unit tests and local development, a local mail catcher is faster and costs nothing per run. Each rented inbox is paid from your balance. That is why many teams run real-inbox tests on a schedule or before a release, not on every commit.

Pick the service code

Each order is for one service:

The newest code is the last entry of otp. An entry can hold a numeric code or a verification link. If your app sends a link, open it with page.goto(link) or cy.visit(link).

A small API helper

Both frameworks can share one helper. It needs Node 18 or newer, which has fetch built in. Keep the key in an environment variable or a CI secret.

// otpgmail.ts: shared by Playwright and the Cypress config
import { randomUUID } from "node:crypto";

const HOST = "otpgmail.net";
const KEY = process.env.OTPGMAIL_API_KEY ?? "";
export type Order = { orderId: string; email: string; status: string; otp: { code: string }[] };

async function call<T>(path: string, method = "GET", body?: object): Promise<T> {
  const headers: Record<string, string> = { Authorization: `Bearer ${KEY}` };
  if (body) {
    headers["Content-Type"] = "application/json";
    headers["Idempotency-Key"] = randomUUID(); // a new key for each new order
  }
  const res = await fetch(`https://${HOST}${path}`, { method, headers, body: body && JSON.stringify(body) });
  const json = await res.json();
  if (!json.success) throw new Error(json.error?.code ?? `HTTP ${res.status}`);
  return json.data as T;
}

export async function rentInbox(service: string, domain = "gmail.com"): Promise<Order> {
  const orders = await call<Order[]>("/v1/orders", "POST", { service, domain });
  return orders[0]; // the API returns an array: one order per inbox
}

export async function waitForCode(orderId: string, timeoutMs = 180_000): Promise<string> {
  const end = Date.now() + timeoutMs;
  while (Date.now() < end) {
    const order = await call<Order>(`/v1/orders/${orderId}`);
    if (order.otp.length) return order.otp[order.otp.length - 1].code;
    if (order.status === "cancelled") throw new Error("ORDER_CANCELLED");
    await new Promise((r) => setTimeout(r, 4000)); // poll every 4 seconds
  }
  throw new Error("CODE_TIMEOUT");
}

export async function cancelOrder(orderId: string): Promise<null> {
  await call(`/v1/orders/${orderId}/cancel`, "POST").catch(() => null); // fails once a code has arrived
  return null;
}

Set timeoutMs to fit the service. Some services send within seconds, others take a few minutes.

Playwright: a sign-up test

This test assumes baseURL is set in your Playwright config.

// tests/signup.spec.ts
import { test, expect } from "@playwright/test";
import { rentInbox, waitForCode, cancelOrder } from "../otpgmail";

test("sign-up with an email code", async ({ page }) => {
  test.setTimeout(5 * 60_000); // an email can take minutes
  const order = await rentInbox("ot"); // "ot" = Any service, for your own app
  let gotCode = false;
  try {
    await page.goto("/signup");
    await page.getByLabel("Email").fill(order.email);
    await page.getByRole("button", { name: "Send code" }).click();
    const code = await waitForCode(order.orderId);
    gotCode = true;
    await page.getByLabel("Verification code").fill(code);
    await page.getByRole("button", { name: "Verify" }).click();
    await expect(page.getByText("Welcome")).toBeVisible();
  } finally {
    if (!gotCode) await cancelOrder(order.orderId); // refund when no code arrived
  }
});

The default test timeout is shorter than a slow email, so the test raises it. Each test rents its own inbox, so parallel workers each get their own order and code.

Cypress: tasks and a custom command

Cypress commands run in the browser, but the API key should stay in Node. So the helper runs as Cypress tasks, and custom commands wrap those tasks.

// cypress.config.ts
import { defineConfig } from "cypress";
import { rentInbox, waitForCode, cancelOrder } from "./otpgmail";

export default defineConfig({
  e2e: {
    baseUrl: "https://staging.example.com",
    setupNodeEvents(on) {
      on("task", {
        rentInbox: (service: string) => rentInbox(service),
        waitForCode: (orderId: string) => waitForCode(orderId),
        cancelOrder: (orderId: string) => cancelOrder(orderId),
      });
    },
  },
});
// cypress/support/commands.ts
import type { Order } from "../../otpgmail";

Cypress.Commands.add("rentInbox", (service: string) => cy.task<Order>("rentInbox", service));
Cypress.Commands.add("waitForCode", (orderId: string) =>
  cy.task<string>("waitForCode", orderId, { timeout: 5 * 60_000 }));

declare global {
  namespace Cypress {
    interface Chainable {
      rentInbox(service: string): Chainable<Order>;
      waitForCode(orderId: string): Chainable<string>;
    }
  }
}
// cypress/e2e/signup.cy.ts
let orderId: string | undefined;
let gotCode = false;

beforeEach(() => { orderId = undefined; gotCode = false; });
afterEach(() => { if (orderId && !gotCode) cy.task("cancelOrder", orderId); }); // refund

it("signs up with an email code", () => {
  cy.rentInbox("ot").then((order) => {
    orderId = order.orderId;
    cy.visit("/signup");
    cy.get("input[name=email]").type(order.email);
    cy.contains("button", "Send code").click();
    cy.waitForCode(order.orderId).then((code) => {
      gotCode = true;
      cy.get("input[name=code]").type(code);
      cy.contains("button", "Verify").click();
      cy.contains("Welcome").should("be.visible");
    });
  });
});

Keep the suite reliable and within limits

Every field and error code is in the API docs. For the bigger picture, see the Gmail OTP API overview and renting Gmail for testing sign-up flows.

Frequently asked questions

Can I use a random email address for OTP tests?

Only if your test can read its inbox. A random address such as [email protected] receives nothing you can see, so the test never gets the code. Use a local mail catcher for emails on your own servers. Use a rented inbox when the email must go through real delivery.

Why does my Cypress task time out?

By default, Cypress stops a task after 60 seconds. Waiting for an email can take longer, so pass a larger timeout to cy.task, as the command above does.

Does this work with Selenium or other test runners?

Yes. The same three calls work in any runner: rent, poll, cancel. With Selenium in Python or Java, put the calls in a small helper class and call it from your test. Gmail OTP API in Python shows the polling loop in Python.

Is there a sandbox key for testing?

No. Every order uses your real balance. A test that never receives a code costs nothing, because that order is refunded in full.

Can I use a demo email for OTP tests?

A shared demo address mixes up codes between runs. Rent one inbox per test instead, so each run gets its own order and only its own codes.

Related guides

Get your verification code now

Sign up free. You are only charged when an inbox is issued. No code within 25 minutes — 100% refunded automatically.

Can't find the service you need? Use Add service on the rental page and we will add it if our infrastructure supports it.

Need a Gmail account you can sign in to, not just a code? See buy short-lived Gmail accounts · Refund and warranty