---
title: "Email Testing API Quickstart"
description: "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."
canonical_url: "https://smtp.dev/docs/guides/email-testing-api/"
---

> Index of every page on this site: [llms.txt](https://smtp.dev/llms.txt)

Create an account, send it a message, read the message back as JSON. No SDK. The [API reference](https://smtp.dev/docs/api/) lists every endpoint and field.

## Before You Start

You need a domain with its MX record pointing at `mx.smtp.dev` ([how](https://smtp.dev/docs/setup/domain/)) and a key from the [API Keys](https://smtp.dev/tokens/) 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](https://smtp.dev/docs/api/#rate-limiting), and every response carries `RateLimit-Remaining`.

## Create an Inbox

An inbox is an [account](https://smtp.dev/docs/api/#create-an-account). Create one on your domain:

```bash
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](https://smtp.dev/docs/guides/send-test-email/#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:

```bash
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](https://smtp.dev/docs/api/#list-messages), newest first, 30 to a page. The messages are in `member`, and `view.next` links the next page:

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

Then [get the message](https://smtp.dev/docs/api/#get-a-message) by `id`:

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

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Holds
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        subject
      </code>
      
      , <code>
        from
      </code>
      
      , <code>
        to
      </code>
    </td>
    
    <td>
      What the recipient sees
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        text
      </code>
      
      , <code>
        html
      </code>
    </td>
    
    <td>
      The body. <code>
        html
      </code>
      
       is an array of HTML parts
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        headers
      </code>
    </td>
    
    <td>
      Every header, as received
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        verifications
      </code>
    </td>
    
    <td>
      <a href="/docs/guides/spf-dkim-dmarc/">
        SPF, DKIM, and DMARC
      </a>
      
       results
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        attachments
      </code>
    </td>
    
    <td>
      Each file, with a <code>
        downloadUrl
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        sourceUrl
      </code>
    </td>
    
    <td>
      Where to fetch the raw source
    </td>
  </tr>
</tbody>
</table>

[The raw message](https://smtp.dev/docs/api/#get-a-message-source), 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:

```js
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:

```python
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](https://smtp.dev/docs/features/account/#plus-sign-aliasing) 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](https://smtp.dev/docs/guides/catch-all/) also receives every address on the domain.

## When a Request Fails

<table>
<thead>
  <tr>
    <th>
      Status
    </th>
    
    <th>
      Usually means
    </th>
  </tr>
</thead>

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

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

## Next

- [SSE](https://smtp.dev/docs/api/#real-time-updates) pushes the message instead of polling.
- The [CI guide](https://smtp.dev/docs/guides/ci/) runs the same loop on every push.
- [Playwright](https://smtp.dev/docs/guides/e2e/) and [Cypress](https://smtp.dev/docs/guides/cypress/) wait for the email inside a browser test and follow its link.
- [SMTP Test](https://smtp.dev/docs/guides/send-test-email/) 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](https://smtp.dev/docs/guides/email-clients/) over IMAP.

## Sitemap

See the full [sitemap](https://smtp.dev/sitemap.md) for all pages.
