Email Testing API Quickstart

Create a test inbox over REST, send it a message, and read the subject, body, and SPF, DKIM, and DMARC results as JSON. No SDK.

Create an account, send it a message, read the message back as JSON. No SDK. The API reference lists every endpoint and field.

Before You Start

You need a domain with its MX record pointing at mx.smtp.dev (how) and a key from the API Keys page. The examples read the key from SMTP_DEV_API_KEY.

Every request sends the key in an X-API-KEY header. The limit is 4096 requests per minute, and every response carries RateLimit-Remaining.

Create an Inbox

An inbox is an account. Create one on your domain:

curl -X POST "https://api.smtp.dev/accounts" \
  -H "X-API-KEY: $SMTP_DEV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"address": "qa@test.example.com", "password": "use-a-secret"}'

The response holds the account id and its mailboxes. Keep the id of the mailbox whose path is INBOX.

Send a Message

Your app gets mail into the sandbox one of two ways:

  • SMTP. Point its mailer at send.smtp.dev and log in as a sandbox account (settings). It delivers only to accounts in your sandbox, so a wrong to can't email a customer.
  • MX. Keep the app on its usual provider and send to an address on your domain. The provider delivers it like any other email, which also tests its SPF and DKIM.

To put a message in the inbox without the app, send it from a second account over the API:

curl -X POST "https://api.smtp.dev/accounts/{senderAccountId}/messages/send" \
  -H "X-API-KEY: $SMTP_DEV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": { "address": "sender@test.example.com" },
    "to": [{ "address": "qa@test.example.com" }],
    "subject": "Confirm your address",
    "text": "https://example.com/confirm/abc123"
  }'

The API sets from to the sending account's address. Use it for fixtures. The app's own mail should take the path it takes in production.

Read the Message

List messages, newest first, 30 to a page. The messages are in member, and view.next links the next page:

curl "https://api.smtp.dev/accounts/{accountId}/mailboxes/{mailboxId}/messages" \
  -H "X-API-KEY: $SMTP_DEV_API_KEY"

Then get the message by id:

curl "https://api.smtp.dev/accounts/{accountId}/mailboxes/{mailboxId}/messages/{id}" \
  -H "X-API-KEY: $SMTP_DEV_API_KEY"
FieldHolds
subject, from, toWhat the recipient sees
text, htmlThe body. html is an array of HTML parts
headersEvery header, as received
verificationsSPF, DKIM, and DMARC results
attachmentsEach file, with a downloadUrl
sourceUrlWhere to fetch the raw source

The raw message, headers and MIME parts included, comes back from .../messages/{id}/source as raw. .../messages/{id}/download returns it as an .eml file.

In a Test

Give each run its own address, wait for the message, assert on it, then delete the account:

import assert from 'node:assert'
import { test } from 'node:test'

const API = 'https://api.smtp.dev'
const headers = {
  'X-API-KEY': process.env.SMTP_DEV_API_KEY,
  'Content-Type': 'application/json',
}

async function api(path, options) {
  const res = await fetch(API + path, { headers, ...options })
  assert.ok(res.ok, `${path} returned ${res.status}`)
  return res.status === 204
    ? null
    : res.json()
}

async function waitForMessage(accountId, mailboxId, timeout = 30_000) {
  const messages = `/accounts/${accountId}/mailboxes/${mailboxId}/messages`
  const deadline = Date.now() + timeout
  while (Date.now() < deadline) {
    const { member } = await api(messages)
    if (member[0])
      return api(`${messages}/${member[0].id}`)
    await new Promise(resolve => setTimeout(resolve, 2000))
  }
  throw new Error(`no message after ${timeout}ms`)
}

test('signup sends a confirmation link', async () => {
  const address = `qa-${crypto.randomUUID()}@test.example.com`
  const account = await api('/accounts', {
    method: 'POST',
    body: JSON.stringify({
      address,
      password: crypto.randomUUID(),
    }),
  })
  const inbox = account.mailboxes.find(m => m.path === 'INBOX')

  await signUp(address) // your app sends the email

  const message = await waitForMessage(account.id, inbox.id)
  assert.match(message.subject, /confirm/i)
  assert.match(message.text, /\/confirm\//)

  await api(`/accounts/${account.id}`, { method: 'DELETE' })
})

The same test with pytest. The fixture deletes the account even when the test fails:

import os
import time
import uuid

import pytest
import requests

API = "https://api.smtp.dev"
session = requests.Session()
session.headers["X-API-KEY"] = os.environ["SMTP_DEV_API_KEY"]


def api(method, path, **kwargs):
    res = session.request(method, API + path, **kwargs)
    res.raise_for_status()
    return None if res.status_code == 204 else res.json()


def wait_for_message(account_id, mailbox_id, timeout=30):
    messages = f"/accounts/{account_id}/mailboxes/{mailbox_id}/messages"
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        member = api("GET", messages)["member"]
        if member:
            return api("GET", f"{messages}/{member[0]['id']}")
        time.sleep(2)
    raise TimeoutError(f"no message after {timeout}s")


@pytest.fixture
def inbox():
    address = f"qa-{uuid.uuid4()}@test.example.com"
    account = api("POST", "/accounts", json={"address": address, "password": str(uuid.uuid4())})
    mailbox = next(m for m in account["mailboxes"] if m["path"] == "INBOX")
    yield address, account["id"], mailbox["id"]
    api("DELETE", f"/accounts/{account['id']}")


def test_signup_sends_a_confirmation_link(inbox):
    address, account_id, mailbox_id = inbox
    sign_up(address)  # your app sends the email

    message = wait_for_message(account_id, mailbox_id)
    assert "confirm" in message["subject"].lower()
    assert "/confirm/" in message["text"]

Delete the account when the test ends. Accounts count against the plan limit. To create none, send each run to a plus alias of one account, like qa+<uuid>@test.example.com, and match messages on to. That works on every plan. On Pro and Max, a catch-all also receives every address on the domain.

When a Request Fails

StatusUsually means
401The X-API-KEY header is missing, or the key is invalid
404A wrong id. Message paths take the account id and the INBOX mailbox id, not the address
422The body didn't validate, for example an address on a domain that isn't yours
429More than 4096 requests in a minute. RateLimit-Reset says when to retry

Updates (PATCH) take Content-Type: application/merge-patch+json, not application/json.

Next

  • SSE pushes the message instead of polling.
  • The CI guide runs the same loop on every push.
  • Playwright and Cypress wait for the email inside a browser test and follow its link.
  • SMTP Test sends from the command line or your app's mailer instead of the API.
  • To see the message rendered, open the account in a mail client over IMAP.