Testing in the Sandbox
SMTP and test keys that deliver nothing, simulated bounces, and sandbox webhooks.
The Sandbox is a fake inbox for the mail your code sends in development, staging and CI. It speaks SMTP and the send API exactly like production does, delivers nothing, and keeps every message so you can inspect it. It needs no verified domain, and it is free on every plan.
Send over SMTP
Create an inbox under Sandbox in the dashboard (or POST /api/v1/sandbox/inboxes). Each inbox
has one login, a username starting sbx_ and a password, and takes mail on:
| Port | Security |
|---|---|
2525 |
STARTTLS. Use this one; nothing blocks it. |
2465 |
Implicit TLS |
# .env, Laravel
MAIL_MAILER=smtp
MAIL_HOST=sandbox.smtp.mailyte.com
MAIL_PORT=2525
MAIL_USERNAME=sbx_...
MAIL_PASSWORD=mlt_sbx_...The dashboard shows the host for your deployment and ready-made settings for Laravel, Django, Rails,
Nodemailer, PHPMailer and smtplib.
Unlike live SMTP credentials and API keys, sandbox credentials stay viewable: read them again any time
from the inbox's Credentials sheet or GET /api/v1/sandbox/inboxes/{id}/credentials (needs
sandbox:write). A sandbox password can only drop mail into your own sandbox. secret is null for
an inbox created before this was possible; reset its credentials to get one.
Any From address is accepted, because nothing is delivered. The sandbox login works only on the
sandbox ports, and a production SMTP credential is refused there, so a staging config cannot send
real mail by accident, and a production one cannot fill your sandbox.
Send with a test API key
Create an API key with Mode: Test. Test keys start mk_test_ and use the same endpoints as live
keys:
curl -X POST "$MAILYTE_BASE/api/v1/messages" \
-H "Authorization: Bearer $MAILYTE_TEST_KEY" \
-H 'Content-Type: application/json' \
-d '{
"to": "ada@example.com",
"from": "hello@yourdomain.com",
"subject": "Your receipt",
"text": "Thanks, Ada."
}'A test send is validated like a live one: the scopes, the payload, the verified sender, the
template rendering. If it would fail in production, it fails here with the same error. The only
difference is what happens after that. The message goes into your organization's default sandbox
inbox, and the receipt carries "mode": "test" with sandbox_message_id and sandbox_inbox_id.
A test send never uses credits or your rate limit, and never writes to your delivery log.
Simulate failures
Every normal recipient produces email.accepted and then email.delivered. To exercise your
failure handling, send to a simulator address. A +label is allowed, such as
bounce+signup@sim.mailyte.com.
| Address | Event |
|---|---|
delivered@sim.mailyte.com |
email.accepted, then email.delivered |
bounce@sim.mailyte.com |
email.bounced, hard, 550 5.1.1 |
softbounce@sim.mailyte.com |
email.deferred, 450 4.2.2 |
complaint@sim.mailyte.com |
delivery.complaint |
suppressed@sim.mailyte.com |
email.dropped, reason suppressed |
To get the same outcome for any recipient, add a header:
X-Mailyte-Simulate: bounceIt takes bounce, softbounce, complaint or suppressed. A simulated bounce or complaint exists
only in the sandbox. It never reaches your suppression list, notifications, automations or reports.
Sandbox webhooks
Sandbox webhooks are created on the Sandbox's Webhooks tab, or with POST /api/v1/sandbox/webhooks.
They are separate from live webhooks: a live webhook never receives a sandbox event, and each
sandbox webhook has its own signing secret.
The body and headers match a live delivery exactly, so your handler needs no changes. Verify
X-Webhook-Signature the same way, as described in Receiving webhooks.
Two fields and one header are added, so you can always tell the difference:
{
"id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
"event": "email.bounced",
"environment": "sandbox",
"sandbox_inbox_id": "01JBT8W4...",
"data": { "recipient": "bounce@sim.mailyte.com", "code": "550 5.1.1" }
}X-Webhook-Environment: sandboxPointing a sandbox webhook at a production endpoint is supported, and it is how you check that a
production handler behaves. Branch on environment if the handler must not act on test events.
Each attempt is retried five times with backoff (30 s, 2 min, 5 min, 15 min, 30 min) and recorded with
its status code, duration and the first kilobyte of your response. The dashboard shows them on the
message's Events tab. POST /api/v1/sandbox/webhooks/{id}/test sends a sandbox.test event on
demand.
To exercise what happens after delivery, fire an event on a caught message: the Simulate row on
its Events tab, or POST /api/v1/sandbox/messages/{id}/events with {"event": "clicked", "url": "https://..."}. It takes opened, clicked, bounced, deferred or complained and fires your
sandbox webhooks as email.opened, email.clicked, email.bounced, email.deferred or
delivery.complaint. recipient defaults to the first envelope recipient and must be one of them.
Test your signup email in CI
An end-to-end test that signs a user up has to wait for the confirmation email before it can click the
link. Don't poll the message list: call await, which holds the request open until a matching
message arrives and returns it in full, including html, text, headers and events.
GET /api/v1/sandbox/inboxes/{id}/messages/await?to=&subject=&since=&timeout=| Parameter | Meaning |
|---|---|
to |
A recipient address, matched against the envelope (Bcc included) and the To header, ignoring case |
subject |
Text the subject contains, ignoring case |
since |
ISO 8601. Only messages received after it count. Pass the moment your test started, so a message from an earlier run can never satisfy this one |
timeout |
Seconds to wait, 1 to 30. Default 30 |
It answers 200 with the newest matching message, or 204 No Content when the timeout passes
with no match. A 204 means "not yet", not failure, so call again. At most five waits can be open at
once per organization; the sixth gets 429, so run parallel test workers against separate addresses
rather than one long wait each.
Use a test key (mk_test_…) with the sandbox:read scope, and give every test run its own address so
parallel jobs never see each other's mail. Plus-addressing works: signup+<run id>@example.com.
curl, in any CI:
SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EMAIL="signup+${GITHUB_RUN_ID}@example.com"
./scripts/sign-up.sh "$EMAIL" # whatever triggers the email in your app
for attempt in 1 2 3 4; do # up to 2 minutes
STATUS=$(curl -s -o message.json -w '%{http_code}' -G \
"$MAILYTE_BASE/api/v1/sandbox/inboxes/$SANDBOX_INBOX_ID/messages/await" \
-H "Authorization: Bearer $MAILYTE_TEST_KEY" \
--data-urlencode "to=$EMAIL" \
--data-urlencode "subject=Confirm your email" \
--data-urlencode "since=$SINCE" \
--data-urlencode "timeout=30")
[ "$STATUS" = "200" ] && break
[ "$STATUS" = "204" ] || { echo "await failed: HTTP $STATUS"; cat message.json; exit 1; }
done
[ "$STATUS" = "200" ] || { echo "no confirmation email within 2 minutes"; exit 1; }
CONFIRM_URL=$(jq -r '.data.html' message.json | grep -o 'https://app.example.com/confirm/[^"]*' | head -1)A helper for Node test runners. Both examples below use it; it needs Node 18 or later for
fetch.
// test/support/sandbox.ts
const BASE = process.env.MAILYTE_BASE ?? 'https://api.mailyte.com';
export interface SandboxMessage {
id: string;
subject: string | null;
html: string | null;
text: string | null;
envelope_rcpts: string[];
}
/** Wait for the email to `to`, up to `totalSeconds`. Throws if it never comes. */
export async function awaitEmail(
opts: { to: string; subject?: string; since: Date },
totalSeconds = 90,
): Promise<SandboxMessage> {
const deadline = Date.now() + totalSeconds * 1000;
while (Date.now() < deadline) {
const timeout = Math.max(1, Math.min(30, Math.ceil((deadline - Date.now()) / 1000)));
const params = new URLSearchParams({
to: opts.to,
since: opts.since.toISOString(),
timeout: String(timeout),
...(opts.subject ? { subject: opts.subject } : {}),
});
const res = await fetch(
`${BASE}/api/v1/sandbox/inboxes/${process.env.SANDBOX_INBOX_ID}/messages/await?${params}`,
{ headers: { Authorization: `Bearer ${process.env.MAILYTE_TEST_KEY}` } },
);
if (res.status === 200) return (await res.json()).data as SandboxMessage;
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 1000)); // too many waits open; back off
continue;
}
if (res.status !== 204) throw new Error(`await failed: HTTP ${res.status} ${await res.text()}`);
}
throw new Error(`No email to ${opts.to} within ${totalSeconds}s`);
}
/** The first link in the HTML that starts with `prefix`. */
export function linkFrom(message: SandboxMessage, prefix: string): string {
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\#x26;');
const match = (message.html ?? message.text ?? '').match(new RegExp(`${escaped}[^"'\\s<>]*`));
if (!match) throw new Error(`No ${prefix} link in "${message.subject}"`);
return match[0].replace(/&/g, '&');
}Playwright:
// e2e/signup.spec.ts
import { test, expect } from '@playwright/test';
import { awaitEmail, linkFrom } from '../test/support/sandbox';
test('a new user confirms their email', async ({ page }, testInfo) => {
const since = new Date();
const email = `signup+${testInfo.testId}-${Date.now()}@example.com`;
await page.goto('/signup');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill('correct horse battery staple');
await page.getByRole('button', { name: 'Create account' }).click();
const message = await awaitEmail({ to: email, subject: 'Confirm your email', since });
expect(message.envelope_rcpts).toContain(email);
await page.goto(linkFrom(message, 'https://app.example.com/confirm/'));
await expect(page.getByText('Your email is confirmed')).toBeVisible();
});Jest, for an API-level test with no browser:
// signup.test.ts
import { awaitEmail, linkFrom } from './support/sandbox';
jest.setTimeout(120_000);
test('signup sends a confirmation email with a working link', async () => {
const since = new Date();
const email = `signup+${Date.now()}@example.com`;
const res = await fetch(`${process.env.APP_URL}/api/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: 'correct horse battery staple' }),
});
expect(res.status).toBe(201);
const message = await awaitEmail({ to: email, subject: 'Confirm your email', since });
expect(message.subject).toBe('Confirm your email');
const confirm = await fetch(linkFrom(message, `${process.env.APP_URL}/confirm/`));
expect(confirm.ok).toBe(true);
});Point the app under test at the Sandbox the usual way, with the inbox's SMTP login or a test API key, and it needs no test-only code: the email your CI reads is the email production would have sent.
Limits
| Limit | Value |
|---|---|
| Inboxes per organization | 3 |
| Messages kept per inbox | newest 50 (adjustable down) |
| Retention | 30 days (adjustable down) |
| Messages accepted per month | 1,000 per organization |
| Rate | 5 per second per inbox, bursts of 20 |
| Message size | 10 MB |
| Recipients per message | 50 |
Over a rate limit, SMTP answers 4xx so your client retries. Over the size, recipient or monthly
limit it answers 5xx with a sentence saying which. The API returns the same refusal as 429, 413
or 422. GET /api/v1/sandbox/usage reports where you stand this month.
The API routes are under /api/v1/sandbox and need the sandbox:read or sandbox:write scope.