API Reference

One token. Every inbox.

A small, token-authenticated REST API for reading and clearing test inboxes from your test runner or CI pipeline. No SDK required — plain HTTP in, JSON out.

Base URL: https://sendtrap.dev/api/v1

Authentication

Every inbox has its own API token — find it on the inbox's Settings page, under Integration. Send it as a bearer token on every request. There's no separate account-level key and no OAuth flow: one token, scoped to one inbox, full stop.

bash
curl https://sendtrap.dev/api/v1/inbox \
  -H "Authorization: Bearer <your-inbox-token>"

If your HTTP client can't set an Authorization header, send the token as an X-Api-Token header instead. Requests without either return 401.

Rate limits

Scaled by your team's plan, per token. Exceeding it returns 429 Too Many Requests. Free is generous enough for a tight assert-and-poll loop in CI — if you're hitting it, you're probably polling faster than you need to.

Plan Requests/min
Free60
Starter120
Team300
Business600
Enterprise1,200

See pricing for what each plan includes.

Endpoints

Method Path Description
GET/inboxDetails about the authenticated inbox
GET/messagesList messages (paginated, searchable, filterable, optionally blocking)
POST/assertBlock until a matching message arrives (or timeout), return pass/fail
GET/messages/{id}Full message detail — headers, HTML, text, links, lint checks
GET/messages/{id}/rawRaw RFC 822 source
GET/messages/{id}/htmlRendered HTML body
GET/messages/{id}/compatibilityHTML Check — email-client HTML/CSS support breakdown (Starter+)
GET/messages/{id}/attachments/{attachment}Download an attachment
PATCH/messages/{id}Mark a message read / unread
DELETE/messages/{id}Delete a single message
DELETE/messagesDelete every message in the inbox

List messages

Returns the inbox's messages, newest first. Standard Laravel pagination — data for the page of results, meta for page/total info.

ParamDescription
searchMatches subject, from address, from name
toRecipient contains this address — checked against the To/Cc headers and the SMTP envelope, so BCC'd recipients match too
test_idExact match against X-Sendtrap-Test-Id (see below)
waitSeconds to block if no message matches yet, up to 30 — see Wait & assert
per_pageDefault 50

Tag a message so a test can find it without needing a unique recipient address — set X-Sendtrap-Test-Id on the outgoing mail (any mail library lets you add a custom header) and filter by it later. Handy for flows where you don't control the recipient, like an admin-notification email that always goes to a fixed address.

bash
curl "https://sendtrap.dev/api/v1/messages?test_id=ci-run-482&to=live-mail%2Babc%40example.com" \
  -H "Authorization: Bearer <token>"
200 OK
{
  "data": [
    {
      "id": 33,
      "test_id": "ci-run-482",
      "from_address": "[email protected]",
      "from_name": "Acme Anvils",
      "to": [{ "name": null, "address": "[email protected]" }],
      "envelope_to": ["[email protected]"],
      "subject": "Welcome to Acme Anvils Ltd",
      "size": 7146,
      "is_read": false,
      "has_attachments": false,
      "has_unresolved_merge_tags": false,
      "received_at": "2026-07-13T12:46:08+00:00"
    }
  ],
  "links": { "first": "...", "last": "...", "prev": null, "next": null },
  "meta": { "current_page": 1, "last_page": 1, "per_page": 50, "total": 1 }
}

envelope_to is the SMTP RCPT TO list, captured independently of the To/Cc headers — it's the only reliable way to see a BCC'd recipient, since BCC is invisible in headers by definition. has_unresolved_merge_tags flags a message whose body still contains an unresolved {{ tag }} or %tag% placeholder — see the detail response below for the full list.

Wait & assert

Mail arrives asynchronously, so a naive first request often runs before it lands. Instead of sleep-polling yourself, add wait=<seconds> to GET /messages — if nothing matches yet, the request blocks (checking every ~250ms–1s) until a match arrives or the timeout hits, capped at 30s. One request, no sleep loop, no extra round trips.

bash
curl "https://sendtrap.dev/api/v1/messages?test_id=ci-run-482&wait=15" \
  -H "Authorization: Bearer <token>"

POST /assert takes the same matching idea further: give it a condition, it waits for a match (or the timeout) and always returns 200 with a pass/fail flag in the body — an unmatched assertion is an expected test outcome, not an HTTP error, so you don't need special-case error handling in your assertion library.

bash
curl -X POST https://sendtrap.dev/api/v1/assert \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"test_id": "ci-run-482", "subject_contains": "Welcome", "timeout": 10}'
200 OK
{
  "matched": true,
  "message": { "id": 33, "test_id": "ci-run-482", "subject": "Welcome to Acme Anvils Ltd", "..." : "..." }
}

/assert accepts to, test_id, subject_contains and timeout (seconds, capped at 30, omit or 0 for an instant check with no blocking). Both endpoints share a tighter rate limit than ordinary API calls — a blocking request can hold a connection open for its full timeout, so keep parallel wait/assert calls modest (a handful per token, not one per test in a large matrix).

min_compatibility_score (0–100, Starter plan and above) additionally requires the matched message's HTML Check compatibility ratio to be at or above this value — handy for gating a CI run on email-client compatibility, not just on the message arriving. Only the matched message is checked (not the whole inbox), so this adds at most one HTML Check's worth of latency to the request.

Get a message

Full detail for one message — parsed HTML and text bodies, all headers, and attachment metadata.

bash
curl https://sendtrap.dev/api/v1/messages/33 \
  -H "Authorization: Bearer <token>" | jq '.data.subject'
200 OK
{
  "data": {
    "id": 33,
    "inbox_id": 15,
    "message_id": "[email protected]",
    "test_id": "ci-run-482",
    "envelope_from": "[email protected]",
    "envelope_to": ["[email protected]"],
    "from_address": "[email protected]",
    "from_name": "Acme Anvils",
    "to": [{ "name": "Test User", "address": "[email protected]" }],
    "cc": [],
    "subject": "Welcome to Acme Anvils Ltd",
    "size": 7146,
    "is_read": false,
    "has_html": true,
    "has_text": true,
    "has_attachments": false,
    "has_unresolved_merge_tags": false,
    "unresolved_merge_tags": [],
    "received_at": "2026-07-13T12:46:08+00:00",
    "html": "<html>...</html>",
    "text": "Welcome...",
    "links": ["https://acmeanvils.com/verify?token=abc123"],
    "checks": [
      { "key": "missing_text_part", "passed": true, "severity": "warn" },
      { "key": "oversized_html", "passed": true, "severity": "warn" },
      { "key": "missing_list_unsubscribe", "passed": true, "severity": "info" },
      { "key": "from_address_present", "passed": true, "severity": "error" }
    ],
    "headers": [{ "name": "Subject", "value": "Welcome to Acme Anvils Ltd" }],
    "attachments": [],
    "urls": {
      "raw": "https://sendtrap.dev/api/v1/messages/33/raw",
      "html": "https://sendtrap.dev/api/v1/messages/33/html"
    }
  }
}

links is every href pulled from the HTML body — handy for asserting a verification link's full shape (including query string) without wrestling with &amp;-encoded entities in the raw HTML, or for feeding it back into a test to complete a verification round-trip. checks is a lint report — each entry's passed is false when that check fails; treat it as a heuristic, not a guarantee (broken-image-URL checking isn't included, since that would mean this service fetching arbitrary remote URLs on your behalf).

Messages belonging to a different inbox than the one your token authenticates always return 404 — never a 403, so you can't probe for the existence of IDs outside your inbox.

HTML Check Starter+

Checks a message's HTML/CSS against caniemail.com's email-client feature-support data and flags anything unsupported or partially supported, and in which clients. Computed on first request and cached — later requests for the same message return instantly unless the underlying support data has since been refreshed. The HTML Check tab in the dashboard is available on every plan; this API endpoint (plus the checks[] summary entry below and assert's min_compatibility_score) requires Starter or above.

bash
curl https://sendtrap.dev/api/v1/messages/33/compatibility \
  -H "Authorization: Bearer <token>"
200 OK
{
  "status": "ok",
  "compatibility_ratio": 76.5,
  "issues": [
    {
      "feature_id": "css-gap",
      "title": "gap, column-gap, row-gap",
      "category": "css",
      "severity": "error",
      "unsupported_clients": [
        { "client": "outlook", "platform": "windows", "support": "n", "note": null },
        { "client": "yahoo", "platform": "desktop-webmail", "support": "n", "note": null }
      ]
    }
  ],
  "checked_at": "2026-07-13T12:47:03+00:00"
}

compatibility_ratio is the percentage of distinct HTML/CSS features detected in the message that are fully supported across a fixed reference set of major clients (Apple Mail, Gmail, Outlook, Yahoo and a few others) — equally weighted, not market-share weighted, since no reliable market-share dataset exists. Treat it as a rough filter for CI gating (see min_compatibility_score in Wait & assert), not a precise "% of your subscribers will see this correctly" figure.

On plans below Starter, this endpoint returns 403, and the checks[] array on Get a message simply omits the html_compatibility entry — it's never forced to compute just because you fetched a message, so it only appears once something (the dashboard tab or this endpoint) has actually run the check.

Raw & rendered HTML

urls.raw and urls.html on a message's detail response are ready-to-fetch links (same bearer token required) — useful if you want the full RFC 822 source or a sandboxed-render-ready HTML string without re-fetching the message detail.

bash
curl https://sendtrap.dev/api/v1/messages/33/raw \
  -H "Authorization: Bearer <token>"

Attachments

Each attachment listed on a message detail response includes its own bearer-token-authenticated url — fetch it directly to get the file bytes. It also includes a checksum (sha256 of the raw content) and content_type, so you can assert an attachment is present and non-trivial without downloading it.

bash
curl https://sendtrap.dev/api/v1/messages/33/attachments/9 \
  -H "Authorization: Bearer <token>" \
  -o invoice.pdf

Mark as read

PATCH with an is_read boolean (defaults to true).

bash
curl -X PATCH https://sendtrap.dev/api/v1/messages/33 \
  -H "Authorization: Bearer <token>" \
  -d is_read=true

Delete messages

Delete one message by ID, or clear the whole inbox in one call — the pattern most CI pipelines want between test runs so message counts stay predictable.

one message
curl -X DELETE \
  https://sendtrap.dev/api/v1/messages/33 \
  -H "Authorization: Bearer <token>"
whole inbox
curl -X DELETE \
  https://sendtrap.dev/api/v1/messages \
  -H "Authorization: Bearer <token>"
# → { "deleted": 12 }

Errors

StatusMeaning
401Missing or invalid token
403Request IP not on the inbox's allowlist (if configured)
404Message/attachment not found, or belongs to a different inbox
429Rate limit exceeded — see plan limits above

CI recipe

Mail arrives asynchronously, so don't assume it's there the instant your app finishes the request. Use /assert instead of hand-rolling a sleep loop — one request, no flaky timing, and a tagged test_id means parallel runs sharing an inbox can't see each other's mail even without unique recipient addresses.

assert-welcome-email.sh
# tag the outgoing mail with X-Sendtrap-Test-Id: $CI_RUN_ID, then:
curl -s -X POST https://sendtrap.dev/api/v1/assert \
  -H "Authorization: Bearer $SENDTRAP_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"test_id\": \"$CI_RUN_ID\", \"subject_contains\": \"Welcome to Acme Anvils Ltd\", \"timeout\": 10}" \
  | tee /tmp/assert.json | jq -e '.matched' > /dev/null || { echo "email assertion failed"; cat /tmp/assert.json; exit 1; }

# clean up so the next CI run starts from an empty inbox
curl -s -X DELETE https://sendtrap.dev/api/v1/messages \
  -H "Authorization: Bearer $SENDTRAP_TOKEN"

Store the token as a CI secret (e.g. SENDTRAP_TOKEN) — never commit it. Give each parallel test environment (staging, PR previews, per-branch) its own inbox and its own token so runs can't see each other's mail — or, if provisioning a separate inbox per run isn't practical, a unique test_id per run does the same job inside a single shared inbox.

Prefer manual polling, or working in a language/tool where a raw POST is awkward? A plain GET /messages loop still works exactly as before — the examples below use that style, and every one of them can be simplified by swapping the loop for a single ?wait=10 request instead.

PHPUnit (Laravel)

retry() gives you the same poll-until-arrived behavior as the bash loop above; clear the inbox in tearDown() so every test starts clean.

tests/Feature/WelcomeEmailTest.php
use Illuminate\Support\Facades\Http;
use RuntimeException;

class WelcomeEmailTest extends TestCase
{
    protected function sendtrap(): \Illuminate\Http\Client\PendingRequest
    {
        return Http::withToken(config('services.sendtrap.token'))
            ->baseUrl('https://sendtrap.dev/api/v1');
    }

    protected function tearDown(): void
    {
        // clean up so the next test starts from an empty inbox
        $this->sendtrap()->delete('/messages');
        parent::tearDown();
    }

    public function test_it_sends_a_welcome_email(): void
    {
        User::factory()->create(['email' => '[email protected]']);

        // poll for up to ~10s — mail arrives asynchronously
        $message = retry(10, function () {
            $data = $this->sendtrap()->get('/messages')->json('data');
            throw_unless($data, RuntimeException::class, 'no message yet');

            return $data[0];
        }, 1000);

        $this->assertSame('Welcome to Acme Anvils Ltd', $message['subject']);
    }
}

Jest / Vitest (Node.js)

welcome-email.test.js
const sendtrap = (path, opts = {}) =>
  fetch(`https://sendtrap.dev/api/v1${path}`, {
    ...opts,
    headers: { Authorization: `Bearer ${process.env.SENDTRAP_TOKEN}` },
  }).then((r) => r.json());

afterEach(async () => {
  // clean up so the next test starts from an empty inbox
  await sendtrap('/messages', { method: 'DELETE' });
});

test('sends a welcome email', async () => {
  await createUser({ email: '[email protected]' });

  // poll for up to ~10s — mail arrives asynchronously
  let message;
  for (let i = 0; i < 10 && !message; i++) {
    const { data } = await sendtrap('/messages');
    message = data[0];
    if (!message) await new Promise((r) => setTimeout(r, 1000));
  }

  expect(message?.subject).toBe('Welcome to Acme Anvils Ltd');
});

pytest (Python)

test_welcome_email.py
import os, time, requests, pytest

BASE = "https://sendtrap.dev/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SENDTRAP_TOKEN']}"}

@pytest.fixture(autouse=True)
def clean_inbox():
    yield
    # clean up so the next test starts from an empty inbox
    requests.delete(f"{BASE}/messages", headers=HEADERS)

def test_sends_a_welcome_email():
    create_user(email="[email protected]")

    # poll for up to ~10s — mail arrives asynchronously
    message = None
    for _ in range(10):
        data = requests.get(f"{BASE}/messages", headers=HEADERS).json()["data"]
        if data:
            message = data[0]
            break
        time.sleep(1)

    assert message["subject"] == "Welcome to Acme Anvils Ltd"

Coming from Mailtrap

Migrating a Mailtrap Email Sandbox test helper? Every endpoint you're already calling has a compatible alias under /api/sandboxes/{sandbox}/... — swap the base URL and token and it should just work.

before → after
# Mailtrap
curl https://sandbox.api.mailtrap.io/api/sandboxes/12345/messages \
  -H "Api-Token: <token>"

# Sendtrap — same shape, your inbox's token, sandbox id is ignored
curl https://sendtrap.dev/api/sandboxes/12345/messages \
  -H "Authorization: Bearer <your-inbox-token>"

The {sandbox} segment is accepted but not checked — your bearer token already scopes every request to one inbox, so whatever sandbox ID your old script has hardcoded is fine to leave in place.

Method Path Mailtrap equivalent
GET/sandboxes/{s}/messagesGet Messages (search, page, last_id)
GET/sandboxes/{s}/messages/{id}Show Email Message
PATCH/sandboxes/{s}/messages/{id}Update Message — {"message":{"is_read":true}}
DELETE/sandboxes/{s}/messages/{id}Delete Message
GET.../messages/{id}/body.txtGet Text Message Body
GET.../messages/{id}/body.htmlGet Formatted HTML Message
GET.../messages/{id}/body.htmlsourceGet HTML Message Source
GET.../messages/{id}/body.rawGet Raw Message Body
GET.../messages/{id}/body.emlGet Message as EML
GET.../messages/{id}/mail_headersGet Mail Headers
GET.../messages/{id}/attachmentsGet Attachments
GET.../attachments/{id}Get Single Attachment
GET.../attachments/{id}/download(file bytes, not just metadata)
PATCH/sandboxes/{s}/cleanClean Sandbox
PATCH/sandboxes/{s}/all_readMark All as Read

Not implemented — these have no real equivalent in Sendtrap's model, so we don't fake them: message templates (template_id/template_variables), HTML client-compatibility analysis, spam/blacklist reports, message forwarding, POP3 access, and account-level project/sandbox management (create, list, or delete sandboxes) — a Sendtrap token is already scoped to one inbox, so there's nothing to list or switch between.

Ready to wire it up?

Create a free inbox, grab its token from Settings, and you're making assertions in CI within minutes.

Create your inbox

No credit card required