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.
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 |
|---|---|
| Free | 60 |
| Starter | 120 |
| Team | 300 |
| Business | 600 |
| Enterprise | 1,200 |
See pricing for what each plan includes.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /inbox | Details about the authenticated inbox |
| GET | /messages | List messages (paginated, searchable, filterable, optionally blocking) |
| POST | /assert | Block 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}/raw | Raw RFC 822 source |
| GET | /messages/{id}/html | Rendered HTML body |
| GET | /messages/{id}/compatibility | HTML 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 | /messages | Delete 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.
| Param | Description |
|---|---|
| search | Matches subject, from address, from name |
| to | Recipient contains this address — checked against the To/Cc headers and the SMTP envelope, so BCC'd recipients match too |
| test_id | Exact match against X-Sendtrap-Test-Id (see below) |
| wait | Seconds to block if no message matches yet, up to 30 — see Wait & assert |
| per_page | Default 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.
curl "https://sendtrap.dev/api/v1/messages?test_id=ci-run-482&to=live-mail%2Babc%40example.com" \
-H "Authorization: Bearer <token>"
{
"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.
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.
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}'
{
"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.
curl https://sendtrap.dev/api/v1/messages/33 \
-H "Authorization: Bearer <token>" | jq '.data.subject'
{
"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 &-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.
curl https://sendtrap.dev/api/v1/messages/33/compatibility \
-H "Authorization: Bearer <token>"
{
"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.
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.
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).
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.
curl -X DELETE \
https://sendtrap.dev/api/v1/messages/33 \
-H "Authorization: Bearer <token>"
curl -X DELETE \
https://sendtrap.dev/api/v1/messages \
-H "Authorization: Bearer <token>"
# → { "deleted": 12 }
Errors
| Status | Meaning |
|---|---|
| 401 | Missing or invalid token |
| 403 | Request IP not on the inbox's allowlist (if configured) |
| 404 | Message/attachment not found, or belongs to a different inbox |
| 429 | Rate 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.
# 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.
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)
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)
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.
# 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}/messages | Get 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.txt | Get Text Message Body |
| GET | .../messages/{id}/body.html | Get Formatted HTML Message |
| GET | .../messages/{id}/body.htmlsource | Get HTML Message Source |
| GET | .../messages/{id}/body.raw | Get Raw Message Body |
| GET | .../messages/{id}/body.eml | Get Message as EML |
| GET | .../messages/{id}/mail_headers | Get Mail Headers |
| GET | .../messages/{id}/attachments | Get Attachments |
| GET | .../attachments/{id} | Get Single Attachment |
| GET | .../attachments/{id}/download | (file bytes, not just metadata) |
| PATCH | /sandboxes/{s}/clean | Clean Sandbox |
| PATCH | /sandboxes/{s}/all_read | Mark 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 inboxNo credit card required