Mail.tdPremium

Email Testing API: How to Test Signup, OTP and Reset Emails

Your tests stop at “check your inbox.” An email testing API gives them a real inbox to read over HTTP — create addresses on demand, catch the verification code, assert, move on.

Most end-to-end tests stop at the same sentence: "Check your inbox." The form submits, the app sends a mail, and the test has no way to open it. So the last step — clicking the link, typing the code — gets done by a human, or never.

An email testing API fixes that. Your test creates a real inbox, your app sends to it, and the message comes back as JSON a second later.

How an email testing API fits between your app and your test suite

What is an email testing API?

An email testing API is a service that receives real email at addresses you create on demand, and hands the messages back to your code over HTTP. No mail client, no shared account, no manual step.

That is what the Mail.td API does. The temp mail inbox on the homepage is the same thing with a human in front of it — same domains, same mail servers, same messages. One door is a web page; the other is a REST endpoint.

Why email is the hardest part of an end-to-end test

Most teams end up with one of these, and each one gives something up:

ApproachWhat it provesWhere it breaks
A shared Gmail or Outlook accountMail really got deliveredTwo CI jobs grab the same unread message; logins and security prompts get in the way
A local SMTP catcherYour code called send()The mail never leaves the machine — no real delivery, no real headers
A public temp mail websiteIt's free and instantNo API to depend on, and anyone who guesses the address can read your reset links
An email testing APIReal delivery, read from codeYou need an account and a token

The last row is the only one where the test does the whole flow the way a user would.

How to get a test inbox over HTTP

Sign in at Mail.td, create an API token in the dashboard, and you are two requests away from a working inbox.

export TD_TOKEN="td_xxxxxxxxxxxxxxxxxxxx"

# 1. Make an inbox
curl -X POST https://api.mail.td/api/accounts \
  -H "Authorization: Bearer $TD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"address": "signup-4821@example.com", "password": "s3cret"}'

# 2. Read what arrived
curl https://api.mail.td/api/accounts/signup-4821@example.com/messages \
  -H "Authorization: Bearer $TD_TOKEN"

The first call returns the inbox id and address. The second returns a short list — id, sender, subject, created_at, is_read — and fetching one message by its id gives you the whole thing: text_body, html_body and attachments[].

These are ordinary addresses on real domains, not fakes. GET /api/domains lists the domains you can use and is the one endpoint that needs no token at all.

How to catch a verification code in a test

Every email-dependent test is the same three steps: make an inbox, use it in the UI, wait for the mail. Here is the whole helper — plain fetch, so it drops into Playwright, Cypress, Selenium, Jest or a shell script unchanged.

const AUTH = { Authorization: `Bearer ${process.env.TD_TOKEN}` };

async function newInbox(label) {
  const address = `${label}-${Date.now()}@example.com`;
  await fetch("https://api.mail.td/api/accounts", {
    method: "POST",
    headers: { ...AUTH, "Content-Type": "application/json" },
    body: JSON.stringify({ address, password: "s3cret" }),
  });
  return address;
}

async function waitForCode(address, timeoutMs = 30000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const { messages } = await fetch(
      `https://api.mail.td/api/accounts/${address}/messages`,
      { headers: AUTH },
    ).then((r) => r.json());

    if (messages.length) {
      const msg = await fetch(
        `https://api.mail.td/api/accounts/${address}/messages/${messages[0].id}`,
        { headers: AUTH },
      ).then((r) => r.json());
      return msg.text_body.match(/\b\d{6}\b/)?.[0];
    }
    await new Promise((r) => setTimeout(r, 1000));
  }
  throw new Error(`No mail for ${address} after ${timeoutMs}ms`);
}

Swap the regular expression for a link matcher and the same helper tests password resets and magic links.

Webhooks or polling: which one for test email?

Polling means asking again and again until the mail shows up. It works everywhere, but every test pays for the wait.

A webhook flips it around: you register a URL once, and Mail.td posts each new message to you the moment it lands.

Polling asks over and over; a webhook delivers the message once, as it arrives

The payload is an email.received event, and it already contains from, to, subject, text_body, html_body and attachments[] — so there is nothing left to fetch.

Every delivery is signed, so you can tell a real one from a fake. Each request carries X-Webhook-ID, X-Webhook-Timestamp and X-Webhook-Signature: sha256=<hex>. The signature is an HMAC-SHA256 of timestamp + "." + raw body, keyed with your webhook secret:

import crypto from "node:crypto";

function verify(rawBody, headers, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${headers["x-webhook-timestamp"]}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(`sha256=${expected}`),
    Buffer.from(headers["x-webhook-signature"]),
  );
}

Check it against the raw bytes, before parsing the JSON. Webhooks are a Pro feature; secrets can be rotated, and past delivery attempts are listed in the API, so a hook that quietly stopped firing is something you can look up.

Rule of thumb: if your test runner can accept an inbound HTTP request, use a webhook. If it is behind NAT or a laptop firewall, poll.

How to run email tests in parallel

Give every job its own inbox and the shared-mailbox race disappears. Put the run and shard number in the address:

reset-${RUN_ID}-${SHARD}@example.com

Now a failing build's mail is easy to tell apart from the next run's — which matters more than it sounds when you are reading a log three days later.

There is no cleanup step to write. Inboxes are cheap, old messages expire on their own, and a test that dies halfway leaves nothing behind for the next run to trip over.

How to test email on your own domain

If staging receives on a shared domain but production receives on yours, one path stays untested. Adding a custom domain puts your own MX records in front of the same API: verify the domain once, then create inboxes on it exactly like any other. Your tests then travel the same route your real mail does.

What you can check inside a message

Because the message is data, the assertions get specific:

  • text_body and html_body — copy, links and markup. Catches a template that lost its footer, or a reset link pointing at the wrong environment.
  • attachments[] — filename, type and size, before you download anything. A 0-byte invoice PDF fails fast.
  • The raw message source — for header checks: the From that really went out, a Reply-To your provider rewrote, a custom header your queue was supposed to add.

Beyond signup: what else teams test with a real inbox

Verification, OTP and password reset are the flows people start with, because they block a user from getting in. They are not where it ends — anything your product sends is worth an assertion:

  • Transactional mail. Order confirmations, receipts, invoices, shipping updates. Did the total match? Did the PDF attach, and is it more than 0 bytes?
  • Account and security notices. Email-changed, password-changed, new-device alerts. These often go to the old address as well — easy to break, rarely tested.
  • Team and billing flows. Invitations, role changes, trial-ending and payment-failed notices, each triggered by a state change rather than a button.
  • Digests and scheduled jobs. A nightly summary that silently stopped sending looks exactly like a quiet week. A test inbox turns that into a failing build.
  • Localised templates. Send the same event in each language you ship and check the subject line, the date format and that no placeholder leaked through untranslated.
  • Negative cases. The mail that must not go out: no reminder after an order is cancelled, no duplicate on a retried webhook, no message to an unsubscribed address.
  • Headers and unsubscribe. The raw source lets you assert on From, Reply-To and List-Unsubscribe — the parts that decide whether bulk mail is treated well.

The mechanics are identical in every case. Make an inbox, trigger the event, read what arrived.

Rate limits, quota and tokens

  • Requests per second: 8/second per IP without a token, 4/second on Free, 10/second on Pro. Go over and you get 429 with rate_limit_exceeded.
  • Monthly operations: Free includes 100 — enough to try the API, not to run a suite. Pro tiers run from 10,000 to 1,000,000 operations per month. A 30-try polling loop can spend 30; a webhook delivery spends none.
  • If the quota runs out, mail keeps arriving — only the API is gated. You lose reads, not messages.
  • Tokens start with td_ and are shown once. Keep one in your CI secrets; revoke it from the dashboard or the API if it leaks.

SDKs for Node.js, Python and Go

npm install mailtd                    # Node.js / TypeScript
pip install mailtd                    # Python
go get github.com/mailtd/mailtd-go    # Go

All three wrap the same endpoints and the same token.

Start with two curl commands

Mint a token, run the two curl calls above against a real domain, and watch a message come back as JSON. That is the entire integration surface — wrapping it in your test framework is an afternoon's work.

The API reference has every endpoint, and Integrating Temp Mail into Tests and CI covers the framework-specific patterns.

Temp mail is the half of Mail.td people see. The receiving pipeline underneath is the half your test suite needs.

Frequently asked questions

What is an email testing API?

It is a service that receives real email at addresses you create on demand and returns the messages to your code over HTTP. Instead of opening a mail client, your test creates an inbox, has the application send to it, then reads the subject, body and attachments as JSON and asserts on them.

How do I test an email verification or OTP flow automatically?

Create a fresh inbox through the API, sign up in the UI with that address, then poll the messages endpoint (or wait on a webhook) until the mail arrives, and pull the code or link out of text_body with a regular expression. The whole helper is about 20 lines of fetch.

Can I use this with Playwright, Cypress or Selenium?

Yes. The API is plain REST with a bearer token, so it works in any test framework that can make an HTTP request — no plugin needed. The same helper function works in Playwright, Cypress, Selenium, Jest or a shell script in CI.

Is email testing only for signup and password reset flows?

No — those are just the flows that block a login, so teams automate them first. The same inbox-in-a-test pattern covers order confirmations and invoices, account and security notices, team invitations, billing and trial-ending mail, nightly digests, localised templates, and negative cases such as a reminder that must not go out after a cancellation.

Should I poll for the message or use a webhook?

Use a webhook if your test runner can receive an inbound HTTP request: the payload already includes the message body, so there is no follow-up call and no polling requests against your quota. Poll when nothing can accept inbound HTTP, such as a laptop behind NAT.

How do I stop two parallel CI jobs from reading the same email?

Give each job its own inbox instead of sharing one. Addresses are created on demand, so putting the run id and shard number in the local part removes the race completely — and there is no cleanup step, since old messages expire on their own.

Is there a free plan for testing the API?

Any registered account can create a td_ token and call the API on the Free tier, which includes 100 operations per month. That is enough to prove out an integration; a suite that runs on every commit will need a Pro tier, which starts at 10,000 operations per month.

Back to blog