Email Testing API Quickstart
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.devand log in as a sandbox account (settings). It delivers only to accounts in your sandbox, so a wrongtocan'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"
| Field | Holds |
|---|---|
subject, from, to | What the recipient sees |
text, html | The body. html is an array of HTML parts |
headers | Every header, as received |
verifications | SPF, DKIM, and DMARC results |
attachments | Each file, with a downloadUrl |
sourceUrl | Where 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
| Status | Usually means |
|---|---|
| 401 | The X-API-KEY header is missing, or the key is invalid |
| 404 | A wrong id. Message paths take the account id and the INBOX mailbox id, not the address |
| 422 | The body didn't validate, for example an address on a domain that isn't yours |
| 429 | More 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.