REST API
Everything the dashboard does, under https://trapit.dev/api/v1 — which is what you want from
a test suite.
Authentication
Mint a key under API keys. Keys start with
trap_ and are shown once — only a hash is
stored, so there is no way to recover one later. Lost a key, revoke it and mint another.
# bearer token curl https://trapit.dev/api/v1/whoami \ -H "Authorization: Bearer trap_…" # or the header, if that is easier in your client curl https://trapit.dev/api/v1/whoami \ -H "X-Api-Key: trap_…"
A key is scoped to the workspace that issued it. There is no cross-workspace read: the API takes no workspace parameter at all, so a key can only ever see its own data.
Resources
Sandboxes
POST /sandboxes # create GET /sandboxes # list GET /sandboxes/{id} PUT /sandboxes/{id} # name + tags PUT /sandboxes/{id}/mocks # the ordered HTTP mock rules DELETE /sandboxes/{id} # takes both streams with it
Filtering the message list
GET …/messages takes, besides limit and cursor:
-
q— free-text, matched case-insensitively as a substring of the subject, sender, recipients, body preview and the captured-for address. -
capturedFor— exact (case-insensitive) match on an address the mail actually arrived on, which is not necessarily theToheader: that is only what the sender wrote. Exact, not substring, sorun-4@…never picks uprun-42@…'s mail. -
since— only messages received at or after an ISO-8601 instant (inclusive: passing the newestreceivedAtyou have seen returns that message again, so de-duplicate by id). isRead—falsefor mail you have not yet consumed.
All filters and-combine. A filtered listing scans the sandbox server-side, so prefer an ephemeral sandbox per run over one giant shared one if you filter constantly — that isolates by construction rather than by filter.
Waiting instead of polling
GET …/messages/wait holds the request open until a matching message arrives —
the REST counterpart of the MCP wait_for_email tool, so a CI fixture needs no
polling loop. It takes subjectContains, fromContains,
capturedFor, isRead and timeoutSeconds (default 30,
max 300). A message already captured that matches returns immediately;
otherwise the first arrival that matches is returned as its full detail. On timeout you get a
204 — distinguishable from the 404 of a sandbox that does not exist.
# block until the invite lands, then mark it consumed curl "https://trapit.dev/api/v1/sandboxes/{id}/messages/wait?subjectContains=invite&timeoutSeconds=60" \ -H "Authorization: Bearer trap_…" curl -X PATCH https://trapit.dev/api/v1/sandboxes/{id}/messages/{messageId} \ -H "Authorization: Bearer trap_…" \ -H "Content-Type: application/json" \ -d '{"isRead": true}'
PATCH with isRead is the non-destructive alternative to deleting a
message once a test has consumed it: combined with isRead=false on the list or
wait call, a handled message never re-matches, but the artifact is still there when a failing
run needs it. false puts a message back.
A create takes a name, optional tags, and optionally
ephemeral with ttlMinutes. The response carries everything
needed to send it traffic — smtpHost, smtpPort,
smtpUsername, smtpPassword and captureUrl — because one
sandbox owns both ways in.
The TTL is 1 to 1440 minutes, default 60;
outside that you get a 400 rather than a quietly adjusted sandbox, as does a
ttlMinutes sent without ephemeral. An ephemeral sandbox expires on its
own, so a CI run needs no cleanup step. Either way it captures the moment the 201
lands — nothing to provision, and nothing to wait for.
curl https://trapit.dev/api/v1/sandboxes \ -H "Authorization: Bearer trap_…" \ -H "Content-Type: application/json" \ -d '{"name": "signup", "ephemeral": true, "ttlMinutes": 30}'
Names are not unique and nothing routes on them — a loop can reuse one across runs. The
smtpPassword is returned on every read, not just the create: it is
viewable by design, unlike an API key.
What a sandbox caught
GET /sandboxes/{id}/messages # trapped email GET /sandboxes/{id}/messages/wait # long-poll for a match GET /sandboxes/{id}/messages/{messageId} PATCH /sandboxes/{id}/messages/{messageId} # {"isRead": true} GET /sandboxes/{id}/messages/{messageId}/raw # original source GET /sandboxes/{id}/messages/{messageId}/attachments/{index} DELETE /sandboxes/{id}/messages # empty this stream only GET /sandboxes/{id}/requests # trapped HTTP GET /sandboxes/{id}/requests/wait # long-poll for a match GET /sandboxes/{id}/requests/{requestId} GET /sandboxes/{id}/requests/{requestId}/raw DELETE /sandboxes/{id}/requests
The two streams are independent: clearing the email one never discards captured requests. Deleting the sandbox is the operation that takes everything.
The request list's q matches method, path, query string, the mock status code,
and the name of the mock rule that answered the request.
…/requests/wait mirrors the messages wait — method (exact),
pathContains (substring) and timeoutSeconds, with the same
200 / 204 / 404 contract.
Account and keys
GET /account GET /whoami # minimal credential probe POST /keys GET /keys DELETE /keys/{id} # revoke
Conventions
- Errors come back as
application/problem+json, with the same shape everywhere. -
List endpoints are cursor-paginated:
limitdefaults to 50 and caps at 200; pass backnextCursoruntil it is null. - Every response carries a correlation id, worth logging if you ever need to ask about one.
Rate limits and quotas
The numbers below are the deployed configuration, quoted live — if this page and the service
ever disagree, something is genuinely broken. Over an API limit you get a 429
with a Retry-After header (honour it rather than spinning); the capture-side
behaviour is on each capture page.
# REST API (this surface, plus per-IP on /mcp and /oauth) per key 300 requests / 60 s window # 429 + Retry-After per IP 900 requests / 60 s window # SMTP capture (per message; over-limit is a 421 temp-fail — senders retry, nothing is lost) per IP 120 messages / 60 s window per org 600 messages / 60 s window size 10 MB max per message (advertised via ESMTP SIZE) # HTTP capture (over-limit 429, oversize 413) per IP 600 requests / 60 s window per org 3000 requests / 60 s window size 5 MB max body
A wait call counts as one request when it starts, however long it then blocks
(max 300 s), so long-polling is far cheaper against the key limit than a
polling loop. Nothing in the API counts your sandboxes or your stored captures and refuses
the next one — the limits it actually applies are the ingress rates above, plus retention:
captured items are purged after 30 days (ephemeral
sandboxes go at their TTL, taking their captures with them).
Separately from any of that, the free tier is sold with ceilings — 100 sandboxes and 1,000,000 captures per organisation. Those are commercial, not technical: no call starts failing when you pass them, and there is no quota error to code against. They are the point at which we would like a conversation, so if a run is heading that way, mail sales@trapit.io and we will sort out what fits.
The generated reference at https://trapit.dev/api-docs has the full schemas and lets you try calls in the browser; the raw document is at https://trapit.dev/openapi/v1.json if you would rather generate a client.