TempMaily.co
Developers

Automate Email Verification Testing with a Temp Mail API

TempMaily Team13 min read

A temp mail API for testing lets your automated tests do what a human tester used to do by hand: create a fresh inbox, trigger a signup against it, wait for the verification email, pull the code out, and finish the flow. All of this happens in code, with no mailbox open in a browser. If your product sends a confirmation email during signup, that email is a wall your end-to-end (E2E) tests slam into. A disposable-mail REST API is how you get through it reliably, in CI, on every run.

This post covers why programmatic inboxes are the right primitive, the exact shape of an automated verification test, and a realistic Node example that polls for a message and extracts a code with a regex. The code here is illustrative: treat the API reference as the source of truth for request and response shapes.

Quick answer

Use a temp mail API in tests when your product requires email verification, password reset, invite acceptance, or any other email-dependent step. The best pattern is to create a unique inbox per test run, trigger the user flow with that address, poll for the expected message with a timeout, extract the code or link from the full message body, then delete or abandon the inbox. This avoids shared-mailbox race conditions, stale test accounts, and manual CI steps.

For CI and E2E tests, polling is usually simpler and more deterministic than webhooks. Use webhooks when the mail should trigger a background system instead of unblocking a test runner.

Why E2E tests need programmatic inboxes

Any signup worth testing has an email step, and that step is where naive test setups break down. The problems are specific:

  • You cannot read a human's mailbox from code. A real Gmail account has no clean, stable API for your test runner to grab "the latest verification email" without OAuth ceremony and rate limits.
  • Shared mailboxes create race conditions. Point ten parallel test runs at one inbox and they collide: run A reads run B's code, and your suite goes flaky in a way that is maddening to debug.
  • Hardcoded test accounts drift. Reusing the same address means stale state: a user that already exists, a verification already consumed, a "this email is taken" error that has nothing to do with the code under test.
  • Manual steps do not belong in CI. A pipeline cannot pause for someone to copy a six-digit code out of Outlook.

A disposable-mail API solves all four by giving every test run its own unique, throwaway inbox that your code fully controls. Each address is isolated, so parallel runs never cross wires; each is fresh, so there is no stale state; and each is discarded after the assertion, so nothing accumulates. This is the same disposability that makes temp mail useful for signups generally, just driven here by a test runner instead of a person.

The automated verification flow

Every email-verification test, regardless of framework, follows the same five beats:

  1. Create an inbox. Call the API to mint a new disposable address. Hold onto both the address and its inbox ID.
  2. Trigger the signup. Drive your app (through Playwright, Cypress, an HTTP client, whatever) using that address as the user's email.
  3. Receive the message. Wait for the verification email to land. Either poll the messages endpoint on an interval, or let a webhook push the message to you.
  4. Extract the code or link. Fetch the full message body and run a regular expression to pull out the numeric code or the confirmation URL.
  5. Assert and finish. Submit the code (or visit the link), then assert the account reaches a verified state.

The only real decision is beat three: poll or push.

Polling vs. webhooks

Both are first-class; pick by context.

  • Poll inside test runners and CI. Your test is already blocking and waiting for a result, so a bounded retry loop (check the messages endpoint every second or two, up to a timeout) fits naturally and keeps everything in one process.
  • Use a webhook for push-based automation outside a test loop: background workers, staging services, or anything that should react to an inbound email without holding a loop open. TempMaily webhooks POST the message to your endpoint the moment it arrives, so you skip polling entirely. Register the endpoint once and let deliveries come to you.

For a test suite, polling is almost always the pragmatic choice. For a long-running service that reacts to mail, reach for the webhook.

The API shape at a glance

TempMaily's API is a small, predictable REST surface under https://tempmaily.co/api/v1, authenticated with a Bearer API key in the Authorization header. The three endpoints you need for a verification test map exactly onto the flow above:

  • Create an inbox (POST /inboxes): mint a new disposable address and get back its id and address.
  • List messages (GET /inboxes/{id}/messages): fetch the message summaries currently in an inbox (this is what you poll).
  • Get a message (GET /messages/{id}): retrieve a single message's full content, including the bodyText and bodyHtml you run your regex against.

For teardown there is also DELETE /inboxes/{id}, and there are webhooks for push delivery when you want messages to come to you instead. Exact paths, parameters, and response fields are documented at /docs/api. Always reconcile your client against that, since the snippet below is intentionally simplified to show the shape, not to be copy-pasted verbatim.

API access is a Premium feature ($9.90/mo), which also gets you the dedicated and custom domains worth using in tests so your own app does not reject the address as disposable.

A realistic Node example

The example below is the poll-and-extract pattern in plain Node. It creates an inbox, then polls until the verification email arrives, then pulls a six-digit code out of the body. Swap the endpoint details to match the reference.

// temp-mail.js — a small client the test frameworks below reuse.
const API = "https://tempmaily.co/api/v1";
const KEY = process.env.XTM_KEY; // Premium API key, xtm_-prefixed

const headers = {
  Authorization: `Bearer ${KEY}`,
  "Content-Type": "application/json",
};

// 1. Create a fresh disposable inbox for this test run.
async function createInbox() {
  const res = await fetch(`${API}/inboxes`, {
    method: "POST",
    headers,
    body: JSON.stringify({ expiry: "1d" }), // "1d" | "7d" | "30d" | "never"
  });
  if (!res.ok) throw new Error(`createInbox failed: ${res.status}`);
  return res.json(); // -> { id, address, token, expiresAt, isPermanent }
}

// 3. Poll the messages endpoint until a matching email lands (or we time out).
async function waitForMessage(inboxId, { timeoutMs = 30000, intervalMs = 2000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(`${API}/inboxes/${inboxId}/messages`, { headers });
    const { messages = [] } = await res.json();
    const hit = messages.find((m) => /verify/i.test(m.subject));
    if (hit) {
      // 4. Fetch the full message so we have the complete body to parse.
      const full = await fetch(`${API}/messages/${hit.id}`, { headers });
      return full.json(); // -> { subject, bodyText, bodyHtml, ... }
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error("Verification email never arrived within timeout");
}

// 4. Pull the code out of the body with a tightly scoped regex.
function extractCode(message) {
  const body = message.bodyText || message.bodyHtml || "";
  const codeMatch = body.match(/\b(\d{6})\b/);
  if (codeMatch) return codeMatch[1];

  // Or extract a confirmation link instead of a numeric code:
  const linkMatch = body.match(/https?:\/\/[^\s"']*\/verify[^\s"']*/i);
  if (linkMatch) return linkMatch[0];

  throw new Error("No verification code or link found in message");
}

// 5. Tear down: delete the inbox and free its storage quota.
async function deleteInbox(inboxId) {
  await fetch(`${API}/inboxes/${inboxId}`, { method: "DELETE", headers }); // 204
}

// The full flow, as it would sit inside a test.
async function runVerification(triggerSignup) {
  const inbox = await createInbox();               // 1
  try {
    await triggerSignup(inbox.address);            // 2  (drive your app here)
    const message = await waitForMessage(inbox.id); // 3
    return extractCode(message);                   // 4  -> submit + assert
  } finally {
    await deleteInbox(inbox.id);                   // 5  always clean up
  }
}

module.exports = { createInbox, waitForMessage, extractCode, deleteInbox };

Note the field names come straight from the reference: the base URL is https://tempmaily.co/api/v1, the API key lives in XTM_KEY as a Bearer credential, and GET /messages/{id} returns bodyText and bodyHtml (not text/html) — parse those or your regex runs on an empty string. The try/finally matters too: teardown runs even when an assertion throws, so a failed test still deletes its inbox instead of leaking one.

Wired into a runner, triggerSignup is where Playwright or Cypress fills your signup form with inbox.address, and the returned code is what you type into the verification field before asserting the account is confirmed.

Wiring it into Playwright, Cypress, and CI

The client above is framework-agnostic on purpose. Here is how it drops into the two most common E2E runners and a CI pipeline.

Playwright

Playwright test code runs in Node, so you can call the client directly and interleave API polling with browser steps. Create the inbox, sign up with its address, poll for the mail, then finish verification in the UI:

import { test, expect } from "@playwright/test";
import { createInbox, waitForMessage, extractCode, deleteInbox } from "./temp-mail";

test("a new user can sign up and verify their email", async ({ page }) => {
  const inbox = await createInbox();
  try {
    // 2. Drive the signup with the disposable address.
    await page.goto("/signup");
    await page.getByLabel("Email").fill(inbox.address);
    await page.getByLabel("Password").fill("Correct-Horse-9");
    await page.getByRole("button", { name: "Create account" }).click();

    // 3–4. Poll the API for the verification mail, then extract the code.
    const message = await waitForMessage(inbox.id);
    const code = extractCode(message);

    // 5. Complete verification in the UI and assert the outcome.
    await page.getByLabel("Verification code").fill(code);
    await page.getByRole("button", { name: "Verify" }).click();
    await expect(page.getByText("Email verified")).toBeVisible();
  } finally {
    await deleteInbox(inbox.id); // clean up even if an assertion fails
  }
});

This is the compact version to get you running. For the full treatment — parallel workers sharing a key, fixtures that create and dispose the inbox automatically, magic-link vs. numeric-code flows, and flake-proofing the poll — see the dedicated Playwright email verification testing guide.

Cypress

Cypress commands run in the browser, so calling the API from a spec would hit CORS and expose your key to the page. The idiomatic fix is cy.task, which runs Node code in Cypress's backend process — do all the temp-mail API work there and hand the results back to the browser:

// cypress.config.js — register the client as tasks (Node side).
const { defineConfig } = require("cypress");
const { createInbox, waitForMessage, extractCode, deleteInbox } = require("./temp-mail");

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on) {
      on("task", { createInbox, waitForMessage, extractCode, deleteInbox });
    },
  },
});
// signup.cy.js — the browser side awaits each task.
it("verifies a new signup", () => {
  cy.task("createInbox").then((inbox) => {
    cy.visit("/signup");
    cy.get("[name=email]").type(inbox.address);
    cy.get("[name=password]").type("Correct-Horse-9");
    cy.contains("Create account").click();

    cy.task("waitForMessage", inbox.id)
      .then((message) => cy.task("extractCode", message))
      .then((code) => {
        cy.get("[name=code]").type(code);
        cy.contains("Verify").click();
        cy.contains("Email verified").should("be.visible");
      });

    cy.task("deleteInbox", inbox.id); // teardown
  });
});

The rule of thumb: any async network call in Cypress belongs in a cy.task, not the spec body — it keeps the API key server-side and sidesteps the browser's same-origin rules.

CI: GitHub Actions

The whole point is that this runs unattended on every push. Store the API key as a repository secret (Settings → Secrets and variables → Actions, never in the repo), then expose it to the test step as XTM_KEY:

name: e2e
on: [push]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
        env:
          XTM_KEY: ${{ secrets.XTM_KEY }}

That is the "tested on every commit" promise made concrete: the signup-and-verify path runs in the pipeline with a real inbox, and no one has to copy a code out of a mailbox by hand.

The webhook variant

If you would rather not poll — say a staging service reacting to inbound mail rather than a blocking test — register a webhook instead. It replaces waitForMessage entirely: TempMaily POSTs the message.received payload to your endpoint the moment mail arrives. Each delivery carries an X-Xtm-Signature header, the hex HMAC-SHA256 of the raw request body keyed by your webhook secret; recompute it over the raw bytes and compare in constant time before trusting the payload. The API docs have a ready-made Node verification snippet.

What to watch for

A few things separate a test that passes once from one that stays green in CI.

  • Scope your regex tightly. \d{6} is fine until an unsubscribe footer or a year in a copyright line matches first. Anchor to nearby text ("Your code is"), or match within the specific element that holds the code.
  • Always bound the poll. A loop with no timeout turns one slow mail delivery into a hung pipeline. Set a deadline and fail loudly.
  • Stay under the rate limit. The API allows 300 requests per 60 seconds per key, and going over returns 429 rate_limited. A 2-second poll interval is ~30 requests a minute, so a single test is nowhere near the ceiling — but many polling tests running in parallel on one key add up. Keep the interval at a second or two (not a tight loop), and if you fan out heavily, give CI its own key.
  • Parse the right body. Some emails put the code only in the HTML part, some only in text. Check both, and remember TempMaily sanitizes HTML: parse the delivered body, not a rendered screenshot.
  • Use a domain your app accepts. If your product blocklists disposable domains, a shared free address will bounce off your own validation — the same dynamic that makes websites block temp mail in the first place. Test with a Premium dedicated or custom domain so the flow behaves like a real user's. This is the flip side of what we cover in can temporary emails be traced — here you want the address to read as legitimate.
  • Keep secrets out of the repo. The API key is a Bearer credential; load it from an environment variable or your CI secret store, never a committed file.
  • Do not assert on a stale inbox. Free inboxes expire; in CI, create a fresh one per run rather than reusing an ID across suites.

The bottom line

A temp mail API for testing turns the email-verification step from the flakiest part of your E2E suite into just another assertion. Create an isolated inbox per run, drive the signup, poll (or receive a webhook) for the message, extract the code with a scoped regex, and finish the flow: no human, no shared mailbox, no race conditions. It is the difference between "we skip the email step in CI" and "our signup is tested end to end on every commit."

Programmatic access is part of TempMaily Premium. Start with the full API reference for exact endpoints and payloads, and if you are new to disposable inboxes in general, what is a temporary email covers the fundamentals the API is built on.

Frequently asked questions

What is a temp mail API for testing?

It is a REST API that lets your test code create disposable inboxes and read the mail they receive, programmatically. Instead of a human opening a mailbox to copy a verification code, your test creates an inbox, points a signup at it, then fetches the message and extracts the code automatically. It makes email-dependent flows testable in CI.

Why can't I just use a shared mailbox for E2E tests?

Shared mailboxes create race conditions: parallel test runs collide on the same messages, and you cannot reliably tell which email belongs to which run. Programmatic disposable inboxes give each test its own unique address, so verification emails never cross wires, and the inbox is discarded afterward.

Should I poll for the email or use a webhook?

Poll in test runners and CI, where the test is already blocking and waiting for a result. Use a webhook for push-based automation — background jobs or services that should react to an inbound email without holding a loop open. TempMaily supports both; webhooks POST the message to your endpoint as it arrives.

How do I extract a verification code from the email body?

Fetch the full message, then run a regular expression over its bodyText or bodyHtml field. For a numeric code, a pattern like a six-digit match works; for a confirmation link, match the href to your verification route. Always scope the regex tightly so you match the code and not an unrelated number in the footer.

Is the TempMaily API a premium feature?

Yes. Programmatic API access, including inbox creation, message reading, and webhooks, is part of TempMaily Premium at $9.90/mo. Authentication uses a Bearer API key. The full endpoint reference lives at /docs/api, which is the source of truth for request and response shapes.

Will disposable domains get rejected by the app I'm testing?

Sometimes, if your own product blocklists known disposable domains. For testing, use a Premium dedicated or custom domain that your app treats as legitimate, so the verification flow behaves exactly as it would for a real user.

Get a free disposable inbox

A live throwaway address, no signup, real-time delivery. Upgrade to Premium for custom domains, forwarding, and no expiry.