Disposable email API for end-to-end tests

Scriptable disposable inboxes, so you can test signup flows without depending on a real mailbox. Five routes, one base at https://inboxsink.com, JSON in and out.

The route that changes anything is wait: it blocks until the message arrives. Without it, an end-to-end test guesses with sleep and turns flaky the moment the sending queue lags.

Free API key

1,000 calls a month, no card asked. Enough to wire it into your test suite and see if it holds.

Use your own domain, webhooks and the API on the Developer plan — 29 € a month. Acceptance of an address is decided by each website.

JavaScript client

A dependency-free package for Node 18 and above. Source on GitHub.

npm i inboxsink
import { InboxSink } from 'inboxsink';
const sink = new InboxSink(process.env.INBOXSINK_API_KEY);

const inbox = await sink.createInbox();
const code = await sink.waitForOtp(inbox.id);   // '204815'

waitForOtp throws rather than returning null when no code arrives: a test should fail loudly, not quietly submit an empty field.

Guides (English), each run against the real service: Playwright · Cypress · Selenium + Python

Authentication

One Authorization header per call. Keys are created from the dashboard and start with ibsk_.

curl -H "Authorization: Bearer ibsk_…" \
  https://inboxsink.com/v1/domains

The plain key is shown only once, at creation. After that only its prefix stays visible — we store nothing but its hash.

Create an inbox

POST /v1/inboxes

Every parameter is optional: domain (a random pool domain by default), prefix (random by default), ttl_seconds (60 seconds to 30 days).

curl -X POST https://inboxsink.com/v1/inboxes \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"ttl_seconds": 900}'

→ 201
{ "id": "42", "address": "9f2c1a@mailhusk.com", "expires_at": "2026-09-01T15:12:00Z" }

An inbox created through the API is private: only the key that created it can read it.

Wait for a message

GET /v1/inboxes/:id/wait

Blocks until a message arrives, then answers immediately. timeout in milliseconds (30 000 by default, 120 000 maximum), since to only get messages after a given id.

Answers 204 with no body if the delay runs out — that is not an error, it is the normal answer to "nothing arrived".

curl -H "Authorization: Bearer $KEY" \
  "https://inboxsink.com/v1/inboxes/42/wait?timeout=45000"

→ 200
{ "message": { "id": "913", "from": "no-reply@acme.com", "subject": "…",
               "otp": "204815", "link": "https://acme.com/confirm/9f3a" } }

otp and link are extracted for you. A code is only reported when the message actually announces one ("code", "verification", "sign in"…) — an invoice total or a year is never mistaken for one.

List messages

GET /v1/inboxes/:id/messages

Takes since and limit (50 by default, 200 maximum). Returns summaries, without message bodies.

Read a message

GET /v1/messages/:id

The whole message: text, html, headers and the attachment list.

Delete an inbox

DELETE /v1/inboxes/:id

Erases the inbox and its messages immediately. Answers 204.

Custom domain

Signup forms that refuse disposable addresses check the domain against public lists. A domain of your own is on none of them, so your tests keep passing. The Developer plan includes one.

  1. Add it from the dashboard. Prefer a subdomain such as qa.example.com: the MX record replaces the mail servers of the name you put it on.
  2. Create the two DNS records the dashboard shows: MX 10 mx.inboxsink.com. on the domain, and a TXT record on _inboxsink. followed by the domain.
  3. Click Verify. Mail is accepted within a minute after that.
// Node
const inbox = await sink.createInbox({ domain: 'qa.example.com', prefix: 'signup-7' });

# HTTP
curl -X POST https://inboxsink.com/v1/inboxes \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"domain": "qa.example.com", "prefix": "signup-7"}'

Then create inboxes on it like on any other domain. Every address on the domain receives mail: an inbox born from an incoming message belongs to your account, is readable by all your keys and by nobody else, and is kept 30 days.

If the subscription ends, the domain stops accepting mail. Nothing is deleted, and it works again when you subscribe again.

Webhooks

Rather than waiting, your server can be called: one signed POST for every message that lands in an inbox of your account, created by one of your keys or on your domain. Public pool inboxes don't trigger webhooks. Endpoints are added from the dashboard (three on the Developer plan), and the secret is shown once.

POST https://your-app.example/hooks/inboxsink
Content-Type: application/json
X-Inboxsink-Event: message.received
X-Inboxsink-Delivery: 913-4
X-Inboxsink-Signature: t=1789286400,v1=4df97a88e366f575…

{ "type": "message.received", "created_at": "2026-09-13T08:00:00.000Z",
  "data": { "inbox_id": "42", "address": "signup-7@qa.example.com",
            "message": { "id": "913", "from": "no-reply@acme.com", "from_name": "Acme",
                         "subject": "Your code", "otp": "204815", "link": null,
                         "received_at": "2026-09-13T08:00:00.000Z" } } }

Check the signature before trusting the body. It is an HMAC-SHA256 of <t>.<raw body> with your secret, the same scheme as Stripe:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyInboxsink(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;   // rejects replays
  return fresh && v1?.length === expected.length &&
    timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

Answer with a 2xx within 10 seconds. Otherwise the call is retried after 10 seconds, 1 minute and 5 minutes; a 4xx other than 408 and 429 is not retried. Only ports 80 and 443 are called, never a private address.

Errors

204No content — for wait, the delay ran out with no message
400Invalid parameter
401Key missing, unknown or revoked
403That inbox belongs to another key
404Inbox or message gone, or already expired
429Too many calls

Limits and lifetime

Public pool inboxes live one hour; inboxes created through the API follow their ttl_seconds, up to thirty days. After that the inbox and its messages are deleted — there is no archive to recover afterwards.

Messages over ten megabytes are refused on arrival. Addresses on an unknown domain are rejected during the SMTP session, without generating a bounce.

These inboxes cannot send mail, and that is deliberate: a message sent from a disposable domain would not arrive anywhere.