Testing an email verification step in Cypress means reaching outside the browser twice: once to get an address the test owns, and once to read the code that lands there. cy.request() is the right tool for both. It runs from Cypress's Node process rather than the page, so CORS never applies, and it can sit in the same command chain as your UI steps. This post builds three custom commands on the Lumbox REST API, then walks through the Cypress-specific parts: where the API key lives, why a timestamp written in the test body is the wrong one, and how to clean up without deleting another machine's inboxes.

Where the API key lives

Recent Cypress versions read secrets with cy.env(). Cypress.env() was deprecated in Cypress 15.10.0 and removed in 16.0, and the difference matters for a key: cy.env() only hands over the variables you ask for, instead of serializing every environment variable into browser state. Locally, put the key in a git-ignored cypress.env.json. In CI, set CYPRESS_LUMBOX_API_KEY as a secret and Cypress picks it up under the name LUMBOX_API_KEY.

The commands

// cypress/support/commands.js
const API = "https://api.lumbox.co/v1";

function lumbox(options) {
  return cy.env(["LUMBOX_API_KEY"]).then(({ LUMBOX_API_KEY }) =>
    cy.request({
      ...options,
      url: `${API}${options.url}`,
      headers: { "X-API-Key": LUMBOX_API_KEY },
    })
  );
}

Cypress.Commands.add("createInbox", (name) =>
  lumbox({ method: "POST", url: "/inboxes", body: { name } }).its("body")
);

Cypress.Commands.add("deleteInbox", (id) =>
  lumbox({ method: "DELETE", url: `/inboxes/${id}` })
);

Cypress.Commands.add("waitForCodeEmail", (inboxId, since, seconds = 60) => {
  const qs = `has_otp=true&timeout=${seconds}&since=${encodeURIComponent(since)}`;
  return lumbox({
    url: `/inboxes/${inboxId}/wait?${qs}`,
    timeout: (seconds + 10) * 1000,
    failOnStatusCode: false,
  }).then((res) => {
    if (res.status === 408) throw new Error(`No code email within ${seconds}s`);
    expect(res.status, "wait status").to.eq(200);
    return res.body;
  });
});

POST /v1/inboxes returns { id, address, name, status, created_at }, and the address is on trylumbox.com unless you pass a verified domain of your own. GET /v1/inboxes/:id/wait is a long-poll: the server checks the inbox once a second and answers as soon as a message received at or after since matches, or returns 408 once timeout seconds (maximum 120) have passed. has_otp=true restricts the match to messages whose parsed otp_codes array is not empty.

Two cy.request() defaults get in the way here. It fails the test on any status outside 2xx and 3xx, so a 408 would surface as a generic request failure, and failOnStatusCode: false lets the command report "no code email" instead. Its timeout is responseTimeout, not defaultCommandTimeout, and the per-request timeout option overrides it, which is why the command sets it ten seconds above the server-side wait.

The spec

// cypress/e2e/signup.cy.js
describe("signup", () => {
  beforeEach(() => {
    const suffix = Math.random().toString(36).slice(2, 8);
    cy.createInbox(`cy-${suffix}`).as("inbox");
  });

  afterEach(function () {
    cy.deleteInbox(this.inbox.id);
  });

  it("verifies the emailed code", function () {
    cy.visit("/signup");
    cy.get("[data-cy=email]").type(this.inbox.address);
    cy.get("[data-cy=password]").type("correct-horse-battery-7");
    cy.get("[data-cy=submit]").click();

    cy.waitForCodeEmail(this.inbox.id, this.inbox.created_at).then((email) => {
      cy.get("[data-cy=otp]").type(email.parsed.otp_codes[0]);
      cy.get("[data-cy=verify]").click();
      cy.url().should("include", "/dashboard");
    });
  });
});

The alias puts the inbox on Mocha's shared context, so the test and the afterEach hook both read it as this.inbox. Both callbacks use function rather than arrows for that reason.

Which timestamp to pass as since

The spec passes the inbox's own created_at as since, and there are two reasons for that. The first is the command queue. A line like const since = new Date() in the test body runs when Cypress builds the queue, before cy.visit() has even started, so it does not mean "just before the click" the way it would in an async/await framework. The second is clocks. created_at comes from the API server, the same clock that stamps incoming mail, so drift on the CI machine cannot push since past the message you want.

For the first code in a fresh inbox, "everything since the inbox existed" is exactly the right window. A resend test needs a later one, because the newest message with a code would still be the first email until the second arrives. Take the new baseline from the first email's received_at, again a server timestamp, inside the chain:

cy.waitForCodeEmail(this.inbox.id, this.inbox.created_at).then((first) => {
  const after = new Date(Date.parse(first.received_at) + 1).toISOString();
  cy.get("[data-cy=resend]").click();
  cy.waitForCodeEmail(this.inbox.id, after).then((second) => {
    cy.get("[data-cy=otp]").type(second.parsed.otp_codes[0]);
    cy.get("[data-cy=verify]").click();
  });
});

Cleaning up without breaking parallel runs

Cypress's own guidance is to reset state in beforeEach and leave it behind after a test, so a failed run can be inspected. Inboxes are an exception worth making. Lumbox counts live inboxes per organization and returns 402 PLAN_LIMIT_EXCEEDED past the plan's limit (3 on Free, 10 on Starter, 50 on Pro, 250 on Scale), and inboxes never expire on their own. Deleting in afterEach keeps a long spec from running into that ceiling.

Hooks do not run when a CI job is killed, so add a sweep for leftovers. The age filter matters if several machines run specs at the same time: without it, one machine's sweep would delete inboxes another machine is using right now.

// cypress/support/commands.js
Cypress.Commands.add("sweepInboxes", (prefix, olderThanMinutes = 30) => {
  const cutoff = Date.now() - olderThanMinutes * 60_000;
  return lumbox({ url: "/inboxes?limit=200" }).then((res) => {
    const stale = res.body.data.filter(
      (i) => i.name?.startsWith(prefix) && Date.parse(i.created_at) < cutoff
    );
    cy.wrap(stale).each((i) => cy.deleteInbox(i.id));
  });
});

// cypress/support/e2e.js
before(() => cy.sweepInboxes("cy-"));

The list endpoint returns newest first, up to 200 per page. If your organization holds more than that, follow cursor while has_more is true.

Running it in CI

# .github/workflows/e2e.yml (excerpt)
- name: Cypress
  env:
    CYPRESS_LUMBOX_API_KEY: ${{ secrets.LUMBOX_API_KEY }}
  run: npx cypress run

Each test makes three Lumbox calls (create, wait, delete) no matter how long the wait takes, because a long-poll is a single request. The API allows 120 requests per minute per key, and the sweep adds one list call per spec file plus one delete for each leftover it finds.

When the wait times out

A 408 means no message with an extracted code arrived. Lumbox pulls 4 to 8 digit codes out of the subject and plain-text body with regular expressions, and codes with letters in them or digits split by a space are not extracted. If your template uses one of those formats, wait without has_otp, filter by subject instead, and read text_body from the response. The OTP extraction post lists the exact patterns, and the Playwright guide explains why these commands use /wait rather than the shorter /otp endpoint.

The full parameter list for /wait is in the inbox docs, and the pricing page has the inbox limit for each plan if you plan to run specs across several machines.