Open beta: PRO is free until we launch payments. We'll email you 30 days before that.

Tell us how it went

The API is young and we are still shaping it. Something missing, confusing, or plainly wrong – we want to hear it.

Docs

Automated email testing with a temp mail API

Sign-up, password reset, magic links, invites: the flows that matter most end in an email. This guide shows how an end-to-end test gets a disposable inbox from the fake-email.pro API, waits for the email your app sends, and reads the OTP code or the confirmation link out of it – with a short example for Playwright, Cypress and Selenium.

Why a disposable inbox in an end-to-end test

  • A fresh address every run. Sign-up refuses an address that is already registered; a new mailbox per test makes the test repeatable without cleaning up users in the app.
  • Real delivery. The email leaves your app through its real mailer and arrives over SMTP, so the test checks what a user gets – the template, the code, the link – not a mock.
  • Isolation. Each test reads its own mailbox, and parallel workers never see each other's email. Mailboxes created with your key are visible to your account only.
  • No shared inbox in CI. No Gmail account, IMAP password or second-factor prompt to keep alive on a build agent – one API key in a CI secret.

The whole cycle

Five steps, three of them API calls. Every request carries Authorization: Bearer fep_...; the key and the first request are in Getting Started, every schema is in the Reference.

  1. POST /v1/mailboxes – Create a mailbox: 201 with its address.
  2. Hand the address to the app under test – type it into the sign-up form, the password reset, the invite.
  3. GET /v1/mailboxes/{address}/messages/wait – Wait for the email. One request that answers the moment the email is in, with the message in short form – id, sender, subject, bodyState, code and confirmLink; after timeout seconds (60 at most) without one it answers 204.
  4. Take the OTP code or the confirmation link: the API read them out of the email when it arrived – code and confirmLink.
  5. DELETE /v1/mailboxes/{address} – Delete the mailbox and its messages when the test is done.

The API is server-to-server: CORS is off, so the calls come from the test runner – Playwright's request fixture, Cypress's cy.request, Python's requests – never from the page under test. Keep the key in an environment variable (FEP_KEY below), not in the repository.

Playwright (TypeScript)

The request fixture calls the API, one request to …/messages/wait waits for the email and brings its code along, and finally deletes the mailbox even when the test fails. The request and the test get timeouts longer than the wait, so the API's 204 – not Playwright's own timeout – is what says the email never came.

import { test, expect } from '@playwright/test'

const API = 'https://api.fake-email.pro/v1'
const auth = { Authorization: `Bearer ${process.env.FEP_KEY}` }

test('sign-up is confirmed with the code from the email', async ({ page, request }) => {
  test.setTimeout(120_000)

  // 1. A fresh mailbox for this test
  const created = await request.post(`${API}/mailboxes`, { headers: auth })
  expect(created.status()).toBe(201)
  const mailbox = await created.json()

  try {
    // 2. The app under test sends its email there
    await page.goto('/sign-up')
    await page.getByLabel('Email').fill(mailbox.address)
    await page.getByRole('button', { name: 'Sign up' }).click()

    // 3. Wait for the email: one request, answered as soon as it is in
    const res = await request.get(`${API}/mailboxes/${mailbox.address}/messages/wait`, {
      headers: auth,
      params: { timeout: 60 },
      timeout: 65_000,
    })
    expect(res.status(), 'no email within 60 s').toBe(200)
    const message = await res.json()

    // 4. The API has already read the one-time code out of the email
    expect(message.code, `no code in «${message.subject}»`).not.toBeNull()

    await page.getByLabel('Code').fill(message.code)
    await expect(page.getByText('Email confirmed')).toBeVisible()
  }
  finally {
    // 5. Clean up
    await request.delete(`${API}/mailboxes/${mailbox.address}`, { headers: auth })
  }
})

Cypress

cy.request runs outside the browser, so CORS does not apply. The helper is a single request with a response timeout past the wait, narrowed to the confirmation email with subject, and the test visits the confirmLink the API found in it.

const API = 'https://api.fake-email.pro/v1'
const auth = { Authorization: `Bearer ${Cypress.env('FEP_KEY')}` }

// Yields the first email that matches the filters: 200 with the message, 204 after 60 s without one.
const waitForMessage = (address, filters = {}) =>
  cy.request({
    url: `${API}/mailboxes/${address}/messages/wait`,
    qs: { timeout: 60, ...filters },
    headers: auth,
    timeout: 65_000,
  }).then(({ status, body }) => {
    if (status !== 200) throw new Error(`No email for ${address}`)
    return body
  })

it('confirms the address through the link in the email', () => {
  cy.request({ method: 'POST', url: `${API}/mailboxes`, headers: auth })
    .its('body.address')
    .then((address) => {
      cy.visit('/sign-up')
      cy.get('input[type=email]').type(address)
      cy.contains('button', 'Sign up').click()

      waitForMessage(address, { subject: 'Confirm' }).then((message) => {
        // The confirmation link the API picked out of the email
        cy.visit(message.confirmLink)
        cy.contains('Email confirmed')
      })

      cy.request({ method: 'DELETE', url: `${API}/mailboxes/${address}`, headers: auth })
    })
})

Selenium (Python)

Selenium drives the browser, requests talks to the API. The helper also shows the one error worth handling in a test: a 429 says how many seconds to wait in Retry-After.

import os
import time

import requests
from selenium import webdriver
from selenium.webdriver.common.by import By

API = "https://api.fake-email.pro/v1"
AUTH = {"Authorization": f"Bearer {os.environ['FEP_KEY']}"}


def wait_for_message(address, **filters):
    for _ in range(3):
        res = requests.get(
            f"{API}/mailboxes/{address}/messages/wait",
            params={"timeout": 60, **filters},
            headers=AUTH,
            timeout=65,
        )
        if res.status_code == 429:
            time.sleep(int(res.headers.get("Retry-After", 1)))
            continue
        if res.status_code == 204:
            break
        res.raise_for_status()
        return res.json()
    raise TimeoutError(f"No email for {address}")


def test_sign_up_with_one_time_code():
    created = requests.post(f"{API}/mailboxes", headers=AUTH)
    assert created.status_code == 201, created.json()["message"]
    address = created.json()["address"]
    driver = webdriver.Chrome()
    try:
        driver.get("https://app.example.com/sign-up")
        driver.find_element(By.NAME, "email").send_keys(address)
        driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

        message = wait_for_message(address)
        assert message["code"], f"No code in {message['subject']!r}"
        driver.find_element(By.NAME, "code").send_keys(message["code"])
    finally:
        driver.quit()
        requests.delete(f"{API}/mailboxes/{address}", headers=AUTH)

Getting the code or the link out

  • code is the one-time code the API found when the email arrived – only a number or token next to a word like «code», «verification», «OTP» or «PIN», in English and ten other languages. It is a string, so a leading zero survives; years, prices, ZIPs in the footer and phone numbers are left out. No code in the email, null.
  • confirmLink is the link that looks like a confirmation – by its text («Verify email», «Confirm», «Sign in») or by its URL; unsubscribe links never are. The URL is exactly what the email carried, tracker redirect included – the browser follows it like a user's would. None, null.
  • When those two are not the ones you need, GET /v1/mailboxes/{address}/messages/{id} with the id from the wait has the whole email: codes and links with every candidate, most likely first, and the full text and html bodies to match your template against.
  • bodyState says what was kept. STORED is the normal case. UNPARSED: the email arrived but could not be read – no sender, subject, bodies or code; only …/raw has it, and it is worth a test failure of its own, since your users would not read it either. OVERFLOW: the email was larger than your account's storage, so only its headers were kept.
  • Wait for the right email, not the first one. The wait answers with the earliest email that matches the filters: subject and from take a substring, case-insensitive. When a flow sends two, wait for the second with after=<id of the first> – the chain never skips an email, even one that arrived before the second request.

Staying inside the limits

A test suite hits the API in bursts, so plan for three things: a monthly quota on creating mailboxes, a per-minute limit on every call and a cap on waits held open at once. Your numbers come from GET /v1/limits; Limits explains them.

  • Create only where a fresh address is the point. Creating a mailbox is the one call that spends the monthly quota; reading never does, and deleting does not give it back. A test that only needs an inbox, not a new user, can reuse one mailbox across its steps.
  • Wait instead of looping. Every call counts against the per-minute limit, and a wait counts once however long it is held open – one wait replaces a loop of reads. Waits held open at the same time are capped per user; past the cap the call is 429 too_many_waits, so keep the number of parallel workers under concurrentWaits.
  • Honour 429. Sleep for Retry-After seconds and retry. A 402 quota_exhausted stops creation until the quota resets on the first of the month; reading keeps working.

What's next

  • Get a key on the temp mail API page – it is free with any account.
  • Need more mailboxes a month than your plan gives? Compare the plans on pricing.