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:
- Your own sign-up flow on staging or production, sending through your real email setup to a real Gmail or iCloud address.
- A third-party sign-up inside your flow, where another service sends the code.
- A delivery check, to confirm that your codes reach Gmail or iCloud at all.
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:
- Signing up for a listed service? Use its code from
GET /v1/services, for examplegitfor GitHub. - Testing your own app? Your app is not on the list, so use
ot("Any service"). It costs more. The Gmail option receives one code per inbox. The iCloud option shows every new email sent to the inbox for 24 hours. It only gives you inboxes you have never rented before.
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
- Rate limits. By default, each API key allows 20 requests per second and 900 per minute. A test that polls every 4 seconds makes 15 requests a minute. So about 60 tests can wait for codes at the same time on one key.
- Waiting orders. One request can rent up to 200 inboxes. The number of orders waiting for a code at once is also capped (
WAITING_LIMIT_REACHED). Cancel orders you no longer need. - Cleanup. Cancelling works only before the first code, and it refunds 100%. Orders left alone cancel themselves at the deadline, 25 minutes at most.
- Retries. Use a new
Idempotency-Keyfor each new order. Reuse a key only when you resend the same request after a timeout. - Test data. Use test data only. With some supplies, an email the system cannot read by itself may go to an AI language service that finds the code.
- Terms. Create test accounts for your own product, or within the target service's terms. OTPGmail is not responsible for misuse.
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.
