API Reference
API Reference
GitHub ↗
Email Sending

Email Sandbox

Capture mail from a staging application instead of delivering it

The sandbox captures mail instead of sending it. A staging application points at Mailyard with a sandbox credential, and every message it produces is stored in full - text part, HTML part, headers, raw bytes, attachments - and delivered to nobody.

It exists for the moment before a feature is real: a signup flow being wired up, a password reset being reworded, a template being checked against a dozen locales. All of that wants to see the message. None of it wants a customer to receive one.

The credential decides, not the code

A sandbox credential is an ordinary API key or SMTP submission credential with one flag set at creation. That is the whole configuration.

SMTP_HOST=mail.example.com
SMTP_PORT=587
SMTP_USER=smtp_a1b2c3...        # <- the only line that differs
SMTP_PASS=...

The application is byte for byte the same as production, which is what makes testing it worth anything. The alternative - a flag in the request body - eventually gets left true on a production deploy, or false on staging, and neither mistake is visible until it is expensive.

The switch is one-way. A sandbox credential cannot ask to send for real:

{
    "from": "...",
    "to": [
        "..."
    ],
    "sandbox": false
}
400  this api key is sandbox-only, so sandbox cannot be set to false -
     use a key without the sandbox flag to send for real

Refused rather than quietly ignored, so nobody walks away believing a message went out. An ordinary credential may still opt in for a single message, which is the direction that cannot leak real mail.

Opting in writes to the sandbox, so it needs the sandbox:write permission like any other sandbox write. An API key holding only emails:write that sets "sandbox": true is answered 403 permission sandbox:write required, and over SMTP an API key without it that sets X-Mailyard-Sandbox is refused with 550 5.7.1. A submission credential carries no permission list and may always opt in.

Creating a sandbox credential

Either kind works, and both are created the same way as their live counterparts:

  • Developers -> API Keys -> Create, tick Sandbox key
  • Developers -> SMTP Submission -> Create, tick Sandbox credential

The flag cannot be changed afterwards. Create a second credential rather than flipping one, so that no single edit can turn every test into a real send.

What is not checked

A sandbox message skips the entire delivery pipeline. It is captured before validation, which means none of the following apply:

Normally requiredIn the sandbox
A verified sender domainAny From address works
An enabled SMTP server or the shared poolNone needed
The suppression listIgnored
The plan’s hourly and daily limitsNot consumed
A registered sender address under strict_sendersNot enforced

That is the point rather than a shortcut. A developer testing a signup flow in a project with nothing configured yet fails every one of those checks, and refusing them would be refusing the feature.

The consequence worth knowing: a sandbox message proves nothing about deliverability. It says what your application composed. Whether a real receiver would accept it is a question only a real send answers.

Reading a captured message

Developers -> Email Sandbox. Each message opens on five views:

  • HTML - rendered in a fully sandboxed frame, with no scripts and no access to the console’s origin. It is your application’s markup, and it is being displayed inside the tool you are signed in to.
  • Text - the plain text alternative, if the message carries one.
  • Headers - everything parsed out, including your own X- headers.
  • Attachments - name, type and size, each downloadable.
  • Raw - the wire bytes exactly as submitted.

The detail page shows the SMTP envelope separately from the From and To headers. They routinely differ - a Bcc recipient appears in the envelope and in no header at all - and that difference is usually the thing being debugged.

Splitting the sandbox into inboxes

One sandbox per project, and often more than one application sending into it. An inbox splits the list without a second credential: it is a name over a list of sender addresses, and the sandbox page gets a dropdown - All mail, then one entry per inbox - that narrows the list to captures whose envelope sender is on that inbox’s list.

Open Inboxes on the sandbox page to create one. Addresses match exactly and without regard to case. A domain on its own does not match anything.

An inbox holds no mail. It is a saved filter, decided every time the list is read:

  • Editing the address list changes what the inbox shows immediately, captures already held included.
  • Deleting an inbox deletes no message. Everything it showed is still under All mail.
  • Empty sandbox asks what to remove: every capture in the project, or only the mail of the inboxes you tick. Mail whose sender is in no inbox is only removed with everything.

Inboxes are sandbox configuration and are gated on the same permissions as the captures: sandbox:read to see them, sandbox:write to create and edit, sandbox:delete to remove.

How long a message is kept

Two limits, and the second is the one that matters in practice. A sandbox belongs to a project, so both are decided by the project’s plan and the project itself. The platform settings are only the default for a project that has not chosen and a plan that says nothing.

LimitDecided by
RetentionThe project’s sandbox_retention_days (project settings), never longer than the plan’s max_sandbox_retention_days. A longer value is clamped to the plan’s ceiling on save. Unset, the platform setting sandbox_retention_days (default 7) applies.
Message capThe plan’s max_sandbox_messages, oldest dropped first. Unset, the platform setting sandbox_max_messages (default 500) applies.

The day window alone does not bound anything useful: a test suite can write ten thousand messages in a morning, and a seven-day window does nothing about that until day seven. The per-project cap is what actually holds the table down, and it is applied on every capture.

Shortening the window for one message

A sender may ask for less retention, never more:

{
    "from": "noreply@example.com",
    "to": [
        "test@example.com"
    ],
    "subject": "CI run 4821",
    "text": "...",
    "sandbox_retention_days": 1
}

Over SMTP the same thing rides on a header:

X-Mailyard-Sandbox-Retention: 1

A value longer than the project’s window is clamped down to it rather than refused, so no application can pin a project’s sandbox open against its plan.

Changing the retention window governs new messages. What is already captured keeps the expiry it was given, so a settings change cannot delete a message somebody is in the middle of reading.

Sending into the sandbox

Over SMTP

Exactly as SMTP Submission describes, with a sandbox credential. The server answers 250 - the message was accepted, by a credential whose meaning is “accept and keep”, so anything else would make every test look like a failure to the library that sent it.

An ordinary credential can opt in for one message:

X-Mailyard-Sandbox: true

Neither control header is stripped from the stored message, unlike on the sending path. The sandbox exists to show exactly what went on the wire, and quietly editing that would hide the header you are trying to confirm you set.

Over HTTP

POST /api/v1/emails/send and POST /api/v1/emails/send-template both capture.

With a sandbox key the request is the ordinary one - that is the entire point. Nothing in the body says sandbox, so the application under test is untouched and only the credential it was given differs:

curl -X POST https://mail.example.com/api/v1/emails/send \
  -H "Authorization: Bearer myk_your_sandbox_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "noreply@example.com",
    "to": ["customer@example.com"],
    "subject": "Order #42 confirmed",
    "html": "<p>Thanks for your order.</p>"
  }'

An ordinary key can opt one message in with a body field. This is the ad-hoc case

  • a single call you want to inspect - not the way to run a test suite:
curl -X POST https://mail.example.com/api/v1/emails/send \
  -H "Authorization: Bearer myk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "noreply@example.com",
    "to": ["customer@example.com"],
    "subject": "Order #42 confirmed",
    "html": "<p>Thanks for your order.</p>",
    "sandbox": true
  }'

There is no X-Mailyard-Sandbox header on this surface. That header belongs to SMTP submission above, where a message has headers and no JSON body to put a field in.

The response is a 201 carrying the sandbox row rather than an email row:

{
    "sandboxed": true,
    "sandbox_email": {
        "id": "3f7c...",
        "source": "api",
        "sender": "noreply@example.com",
        "recipients": [
            "test@example.com"
        ],
        "subject": "Welcome",
        "size": 1284,
        "expires_at": "2026-08-15T09:12:00Z",
        "received_at": "2026-08-08T09:12:00Z"
    }
}

Deliberately not shaped like a send: the id belongs to the sandbox, and looking it up under /api/v1/emails returns 404. A response that invited that mistake would be worse than one that plainly says what happened.

sender and recipients are the envelope, and hold bare addresses: a from of "Acme" <noreply@example.com> is stored as noreply@example.com, exactly as a submission over SMTP would be, so inbox filters and match=exact find it. The display name is in the From header of the raw message.

An Idempotency-Key header works as on a real send: a retry carrying the same key answers 200 with the capture the first request stored and replayed: true, instead of storing a second one. The capture is handed back only to a caller holding sandbox:read, and a key that produced a real message answers a sandbox credential 409.

A template send is rendered first and then captured, so what you read is what the template produced for that data - placeholders resolved, template attachments included.

Batch is not supported

POST /api/v1/emails/batch refuses a sandbox key with a 403 rather than falling through to a real send. Send the items individually.

Reading it from the API

The sandbox has a console API under /api/v1/sandbox, session-authenticated like the rest of the console:

RoutePurpose
GET /api/v1/sandboxPage of captured messages, newest first, ?limit= (50, at most 200) and ?offset=. ?inbox=<id> narrows it to one inbox’s senders, ?sender=, ?recipient= and ?subject= match part of a value, ?match=exact makes the two addresses whole, ?since= keeps what was received after a date (2026-08-01) or an RFC 3339 instant
GET /api/v1/sandbox/infoConnection details and the retention settings
GET /api/v1/sandbox/:idOne message, parsed
GET /api/v1/sandbox/:id/rawThe wire bytes as text/plain
GET /api/v1/sandbox/:id/emlThe same bytes as an .eml download
GET /api/v1/sandbox/:id/attachments/:idxOne attachment
DELETE /api/v1/sandbox/:idDelete one message
POST /api/v1/sandbox/clearEmpty the project’s sandbox. A body of senders or recipients keeps it to captures from or to those addresses, older_than to what was received before that date or instant
GET /api/v1/sandbox/credentialsThe project’s sandbox credentials
POST /api/v1/sandbox/credentialsMint one: name. The password is returned once
GET /api/v1/sandbox/credentials/:idOne credential
PATCH /api/v1/sandbox/credentials/:idRename it
POST /api/v1/sandbox/credentials/:id/revokeRetire it
GET /api/v1/sandbox/inboxesEvery inbox in the project
POST /api/v1/sandbox/inboxesCreate an inbox: name, description, addresses
GET /api/v1/sandbox/inboxes/:idOne inbox
PATCH /api/v1/sandbox/inboxes/:idEdit an inbox
DELETE /api/v1/sandbox/inboxes/:idDelete an inbox. No captured mail is removed

What a sandbox credential may do

A sandbox key is judged on the sandbox resource, not on emails, and that is what makes it safe to hand out.

Ticking the flag is the whole thing. The key carries sandbox:read, sandbox:write and sandbox:delete by itself - send, read back what was sent, clear between runs - so a key created with an empty permission list and "sandbox": true works. Add permissions only if the application under test needs to read something beyond the sandbox.

RouteA sandbox key
POST /emails/sendcaptured
POST /emails/send-templatecaptured
/sandboxread, clear, delete, inboxes, sandbox credentials
everything else under /emails403, reads included
/api-keys, /smtp-credentials403, reads included
any other GET on /api/v1only if you granted the permission
POST /templates/previewonly if you granted the permission
POST /templates/:id/versions/:v/previewonly if you granted the permission
POST /campaigns/:id/previewonly if you granted the permission
POST /subscriber-lists/preview-segmentonly if you granted the permission
any other write on /api/v1403, whatever it holds
POST /api/relay-nodes/register401, a sandbox key enrols no relay node

A sandbox key writes nowhere but the sandbox and the two accepting routes. Members, invitations, roles, webhooks and their tests, SMTP servers, relay nodes, domains, senders, templates, campaigns, subscribers, suppressions, project settings, erasure and imports are all refused with 403 a sandbox key reaches only the sandbox, captured sends and reads, even when the key was given those permissions. The four previews above are the exception: each is a POST that renders or counts and stores nothing, so it is treated as the read it is and answers to the same permission as one (templates:read, campaigns:read, subscribers:read). The permissions you add can widen what it reads, never what it changes.

The delivery log, one message’s detail, batch and retry all describe or touch real mail, so it is turned away from them by name, reads included. The same goes for the credential routes, where it could otherwise mint a credential that is not a sandbox one. It mints through /sandbox/credentials, which makes only sandbox credentials.

A sandbox key holds nothing on emails

emails:write also grants POST /emails/{id}/retry, which re-queues an existing message - one already composed and addressed to a real recipient, with nothing left to capture. A credential handed out precisely so it cannot send real mail must not be able to put a real failed message back on the queue, so a sandbox key holds no permission on emails at all and there is nothing on that surface for it to spend.

Who can see it

Anybody holding sandbox:read, and that is worth granting on its own. A role holding the sandbox and nothing else - no email log, no contacts, no templates, no domains, no export - is exactly what a contractor wiring up a signup flow needs. See Roles .

The sandbox screen recognises that shape and becomes the whole console for whoever carries it, rather than sitting behind a navigation of links that all answer 403.

Handing a contractor a sandbox-only role and a sandbox credential gives them everything they need to build against Mailyard and no view of a single message the company actually sent.

What it is not

  • Not a preview. Template preview renders a template without any of your application in the loop. The sandbox is the other half: your application composed the message, and this is what it produced.
  • Not a deliverability check. Nothing here touched DNS, SPF, DKIM or a receiving server.
  • Not an inbox. Inbound Email receives mail addressed to a domain you verified, from anyone. The sandbox holds mail your own application submitted.