One inbox per worker
POST /v1/inboxes hands back a fresh address on mailwurf.io. Twelve workers running in parallel never read each other’s mail, so retries stop being a coin flip.
Mailwurf is a hosted catch all mailbox for end to end tests. Your app keeps sending through Resend or SES. Your test creates an address, waits for the message, and opens the link.
No mail server to run. No DNS to hold. One API key per project.

const inbox = await mailwurf.createInbox();
// k3f9x2@mailwurf.io
await page.fill("[name=email]", inbox.address);
await page.click("text=Sign up");
const mail = await inbox.waitFor({
subject: "Confirm",
});
await page.goto(mail.links[0]);The whole integration. No polling loop, no shared mailbox, no sleep.
The moment a test rewires where mail goes, it stops covering the part that actually breaks: the provider, the template, the link. Mailwurf changes the recipient instead.
POST /v1/inboxes hands back a fresh address on mailwurf.io. Twelve workers running in parallel never read each other’s mail, so retries stop being a coin flip.
GET /messages/wait blocks until the message arrives, up to 120 seconds. Matching is oldest first, by subject substring, and after takes a message id.
Each message comes back with text, html, links and the detected OTP. The raw MIME stays in the store, because parsers are wrong sometimes.
Mail reaches Mailwurf the way it reaches a customer: through your provider, over MX, with your templates. A broken sender fails the test, not the release.
We hold the MX records, the certificate and the store. You hold an API key. There is no mail server in your stack and no port 25 in your firewall rules.
GET /llm returns a markdown guide to the API, no auth required, so a coding agent can wire up a mail assertion without a client library or a doc site.
Every project gets its own key. Two environment variables in the test runner, and nothing about the app under test changes.
MAILWURF_URL=https://mailwurf.io
MAILWURF_API_KEY=mw_live_9c2f41d8a7One call per test, before the flow starts. You get an id and an address on mailwurf.io. Drop the address into the form the way a real signup would.
const mailwurf = createClient(); // reads both variables
const inbox = await mailwurf.createInbox();
await page.fill("[name=email]", inbox.address);The request stays open until a matching message arrives. Subject is a substring match, oldest message first, and the response carries the parsed links and OTP.
const mail = await inbox.waitFor({
subject: "Confirm your email",
timeout: 15_000,
});
expect(mail.otp).toMatch(/^\d{6}$/);
await page.goto(mail.links[0]);Every call under /v1 takes your project key as a bearer token. The TypeScript client is a fetch wrapper around these calls, so anything that speaks HTTP works the same way.
| Method | Path | Notes |
|---|---|---|
| POST | /v1/inboxes | Optional address, otherwise generated |
| GET | /v1/inboxes?address= | Look up an inbox created by catch all |
| GET | /v1/inboxes/{id}/messages | Filter by subject and after |
| GET | /v1/inboxes/{id}/messages/wait | Blocks, timeout in ms |
| GET | /v1/messages/{id} | Single message with raw MIME |
| DELETE | /v1/inboxes/{id} | Drop the inbox early |
| GET | /llm | Public markdown guide for agents |
| GET | /health | Public status probe |
{
"id": "01J8ZQ2K7M",
"subject": "Confirm your email",
"from": "hello@yourapp.com",
"text": "Your code is 418902",
"links": ["https://yourapp.com/confirm?t=ey..."],
"otp": "418902",
"receivedAt": "2026-08-20T19:04:11Z"
}Parsed fields are a convenience. The original message is stored next to them, so a template change that confuses the parser never silently changes what your assertions see.
curl -s -X POST https://mailwurf.io/v1/inboxes \
-H "Authorization: Bearer $MAILWURF_API_KEY"
# {"id":"01J8ZQ…","address":"k3f9x2@mailwurf.io"}Anything that speaks HTTP works. The TypeScript client is a fetch wrapper around exactly these calls, with types for the message shape.
MAILWURF_URL=https://mailwurf.ioMailwurf is in private access while we finish the billing side. Tell us how your suite runs and we will tell you which plan fits and what it will cost.
For the first spec, on a shared mailwurf.io domain.
Pricing to be announced
For a suite that runs on every pull request.
Pricing to be announced
For teams that need their own MX name in the address.
Pricing to be announced
Those are SMTP sinks you point your app at, so the test never exercises Resend, SES, your templates or your DNS. Mailwurf receives on real MX records, which means only the recipient address changes and the rest of the path stays production.
No. The app keeps its provider, its credentials and its templates. The test sets the recipient to an inbox address it created a moment earlier.
No. We hold the MX records, the TLS certificate and the store. Your side is two environment variables in the test runner and a call at the start of the spec.
It is accepted and an inbox is created on delivery, as long as the recipient is on your Mailwurf domain. Find it afterwards with GET /v1/inboxes?address=. Recipients on any other domain are refused with 550 relay not permitted.
Every call under /v1 requires your project key, inbox ids are random, and messages are deleted when their retention window ends. Treat Mailwurf as a test system regardless: it is built for mail your test suite generates, not for production mail or real customer data.
Yes. The MX endpoint offers STARTTLS with a certificate covering the MX name, which is what providers check before they hand a message over. Nothing about your own SPF or DKIM setup needs to change.
The timeout query parameter is in milliseconds, defaults to 15000 and is capped at 120000. Matching is oldest message first, subject is a substring, and after takes a message id so a retry does not resolve to the message you already read.
Yes. It is plain HTTP with a bearer token. GET /llm returns a markdown guide to the API without auth, which is also what a coding agent reads before writing the test.
Anything that can make an HTTP request works: Cypress, Vitest, Go tests, a shell script with curl. The TypeScript client is a thin fetch wrapper with no Playwright dependency.
Yes, on the dedicated plan. You delegate an MX record for a subdomain to us and inboxes are created under that name instead of mailwurf.io.
Until the inbox TTL for your plan elapses, or until you call DELETE on the inbox, whichever comes first. Test mail is deleted, not archived.
Pricing is not public yet. Mailwurf is in private access while billing is finished, so write to us with the shape of your suite and we will tell you which plan fits.
Send us the shape of your suite and we set up a project key. One call in the spec, and the mail step stops being the flaky one.
