A signup test that depends on an emailed code needs three things the browser cannot give it: an address that belongs to this test and nothing else, a way to block until the message lands, and the code as a plain string. Playwright handles the browser half. This guide covers the email half with the Lumbox REST API: a helper module, a per-test fixture, the timeouts that cause most of the flakiness, and what to do when a code never shows up.
The helper module
Keep the Lumbox calls in plain fetch so the same file works inside a test, in global setup, and in a cleanup script. Node 18 and later ship fetch and AbortSignal.timeout.
// tests/lumbox.ts
const API = "https://api.lumbox.co/v1";
const headers = {
"X-API-Key": process.env.LUMBOX_API_KEY ?? "",
"Content-Type": "application/json",
};
export type Inbox = { id: string; address: string; created_at: string };
type Filters = { subject?: string; from?: string; hasOtp?: boolean };
export async function createInbox(name: string): Promise<Inbox> {
const res = await fetch(`${API}/inboxes`, {
method: "POST",
headers,
body: JSON.stringify({ name }),
});
if (res.status !== 201) throw new Error(`create inbox ${res.status}: ${await res.text()}`);
return res.json();
}
export async function deleteInbox(id: string): Promise<void> {
await fetch(`${API}/inboxes/${id}`, { method: "DELETE", headers });
}
export async function waitForEmail(inboxId: string, since: Date, f: Filters = {}, seconds = 60) {
const q = new URLSearchParams({ since: since.toISOString(), timeout: String(seconds) });
if (f.hasOtp) q.set("has_otp", "true");
if (f.subject) q.set("subject", f.subject);
if (f.from) q.set("from", f.from);
const res = await fetch(`${API}/inboxes/${inboxId}/wait?${q}`, {
headers,
signal: AbortSignal.timeout((seconds + 10) * 1000),
});
if (res.status === 408) throw new Error(`no matching email within ${seconds}s`);
if (!res.ok) throw new Error(`wait ${res.status}: ${await res.text()}`);
return res.json();
}
export async function waitForCode(inboxId: string, since: Date, f: Filters = {}) {
const email = await waitForEmail(inboxId, since, { ...f, hasOtp: true });
return email.parsed.otp_codes[0] as string;
}
Authentication is the X-API-Key header carrying an ak_ key. The wait calls GET /v1/inboxes/:id/wait, which holds the request open on the server and checks the inbox once a second until a message received at or after since matches the filters, or until timeout seconds pass. The server caps timeout at 120. Running out of time returns HTTP 408, and the helper turns that into an error that names the problem, instead of a JSON parse failure three lines later.
One inbox per test, as a fixture
// tests/fixtures.ts
import { test as base, expect } from "@playwright/test";
import { createInbox, deleteInbox, type Inbox } from "./lumbox";
export const test = base.extend<{ inbox: Inbox }>({
inbox: async ({}, use, testInfo) => {
const suffix = Math.random().toString(36).slice(2, 8);
const inbox = await createInbox(`e2e-w${testInfo.workerIndex}-${suffix}`);
await use(inbox);
await deleteInbox(inbox.id);
},
});
export { expect };
The name becomes the local part of the address: lowercased, with anything outside a-z, 0-9 and - replaced by a hyphen. This test gets something like e2e-w3-k9x2qa@trylumbox.com. Addresses are unique across Lumbox and creating one that already exists returns 409, so the name carries both the worker index and a random suffix. The code after use() is teardown, and Playwright runs it whether the test passed or failed.
The test
// tests/signup.spec.ts
import { test, expect } from "./fixtures";
import { waitForCode } from "./lumbox";
test("sign up with an emailed code", async ({ page, inbox }) => {
test.setTimeout(120_000);
await page.goto("/signup");
await page.getByLabel("Email").fill(inbox.address);
await page.getByLabel("Password").fill("correct-horse-battery-7");
const since = new Date(Date.now() - 5_000);
await page.getByRole("button", { name: "Create account" }).click();
const code = await waitForCode(inbox.id, since);
await page.getByLabel("Verification code").fill(code);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
Take the timestamp before the click. /wait only returns mail received at or after since, and when you leave it out it defaults to the moment the request reaches the server. A quick mail path can deliver the code while the test is still between the click and the wait call, and a default since would then skip it. The five-second margin absorbs clock drift between the CI runner and the API server, and costs nothing because the inbox was created a moment ago and holds nothing older.
Raise the test timeout as well. Playwright's default test timeout is 30 seconds and it includes fixture setup, so a 60-second email wait gets cut off by Playwright before Lumbox has answered. test.setTimeout(120_000) in the test, or timeout in playwright.config.ts, gives the wait room.
Why /wait and not /otp
A shorter endpoint exists: GET /v1/inboxes/:id/otp returns { code, all_codes, from, subject, expires, email_id } and looks back five minutes by default. It only considers emails whose parsed category is verification, and that category comes from keyword rules. A message reading "Your login code is 123456", with no "verify" or "verification code" anywhere in it, gets its code extracted and its category set to general, and /otp waits on it until the 408. /wait?has_otp=true has no category condition, which is why the helper uses it.
Both endpoints look only at the newest matching message. If your app sends a welcome email right after the code email, the newest message has no code and the wait keeps going until it times out. Narrow it with subject or from, which are substring matches, and the helper passes them through for that case.
Verification links instead of codes
Flows that confirm by link work the same way. Every email object carries parsed.verification_links (URLs containing words like verify, confirm or activate, or a token= style parameter) and parsed.magic_links.
const since = new Date(Date.now() - 5_000);
await page.getByRole("button", { name: "Create account" }).click();
const email = await waitForEmail(inbox.id, since, { subject: "Confirm" });
await page.goto(email.parsed.verification_links[0]);
await expect(page.getByText("Email confirmed")).toBeVisible();
Parallel workers and the inbox limit
With this fixture every worker holds one inbox at a time, so the number of live inboxes equals your worker count. Lumbox counts live inboxes per organization and refuses new ones past the plan limit with a 402 and code: "PLAN_LIMIT_EXCEEDED". The limits are 3 on Free, 10 on Starter, 50 on Pro and 250 on Scale. Keep workers at or below that number, minus any inboxes your organization uses for other things. Inboxes never expire on their own, so the delete in teardown is what keeps the count flat across a long run.
Running it in CI
# .github/workflows/e2e.yml (excerpt)
- name: Playwright tests
env:
LUMBOX_API_KEY: ${{ secrets.LUMBOX_API_KEY }}
run: npx playwright test
A long-poll counts as one request however long it waits, so each test spends three API calls: create, wait, delete. The API allows 120 requests per minute per key, which leaves room for about 40 test starts a minute before the budget runs out.
What this does not cover
Lumbox extracts codes with regular expressions: 4 to 8 digit codes next to words like code, PIN or verification, plus a number sitting alone on its own line. Codes that contain letters, or digits split by a space or hyphen, are not extracted, and otp_codes comes back empty. If your app sends that format, the same response includes text_body, and a pattern written for your own template will find it. The post on how OTP extraction works lists what is matched and what is not. SMS codes and authenticator-app TOTP never touch email, so this setup does not help with them. For sweeping up inboxes left behind by a crashed runner, see per-test inbox isolation in CI.
To try the fixture, get a key with npx lumbox signup or through the quickstart, and check the inbox endpoint reference for every filter /wait accepts.