Test email delivery (mail sandbox)
The mail sandbox tests webhook handlers, suppression logic, and delivery outcomes without sending to a real inbox. Send through the normal API to an address at messagebird.dev. The local part determines the outcome: bounce@messagebird.dev bounces and delivered@messagebird.dev delivers.
A sandbox send uses the normal acceptance, event, and webhook paths. It returns the same 202 response and produces the same per-recipient event payload shapes as a production send. The payload has no test flag. The message does not reach external delivery infrastructure or a real inbox. Simulated bounces and complaints do not affect sending reputation or write to the suppression list, so you can reuse the addresses.
The sandbox needs no setup: no toggle, no test mode, no special API key. It triggers purely on the recipient address, on the normal send endpoints (POST /v1/email/messages and POST /v1/email/batches) and on a broadcast: a contact in the audience whose address is a sandbox address is simulated rather than sent to, which is how you rehearse a campaign without mailing anyone. A simulated recipient still counts against your send allowance, so the rehearsal exercises the same quota the real send would.
Magic addresses
All addresses are on @messagebird.dev. The local-part selects the outcome:
| Address | Simulated outcome | Webhook sequence | Notes |
|---|---|---|---|
| delivered@ | The receiving mail server accepts the message | email.accepted → email.processed → email.delivered | The happy path |
| bounce@ / hardbounce@ | Hard bounce: SMTP 550, 5.1.1 Unknown User, bounce class 10 | email.accepted → email.processed → email.bounced with bounce_type: "hard" | No suppression-list write, so the address stays reusable |
| softbounce@ | Soft bounce: SMTP 451, 4.3.0 Temporary failure, please retry, class 20 | email.accepted → email.processed → email.bounced with bounce_type: "soft" | Soft bounces never suppress, real or simulated |
| deferred@ / delay@ | Deferral: SMTP 451, 4.2.1 Mailbox temporarily unavailable, will retry, class 21 | email.accepted → email.processed → email.deferred | The simulated deferral is terminal: no retry follows, so the recipient stays deferred |
| complaint@ / spam@ | The recipient reports the message as spam (feedback_type: "abuse") | email.accepted → email.processed → email.complained | No suppression write; reusable |
| suppressed@ | The recipient is treated as already on your suppression list | email.accepted → email.rejected with rejection_reason: "recipient_suppressed" | Short-circuits during processing exactly like a real suppressed recipient: no email.processed, no delivery events |
| reject@ | The message is rejected before any delivery attempt | email.accepted → email.rejected with rejection_reason: "transmission_failed" | No email.processed or delivery events follow |
A send can mix sandbox and real recipients. Each recipient gets its own lifecycle: real recipients deliver normally, sandbox recipients are simulated.
The same events also land on the message's timeline in the email log and in the events API, so you can drive the sandbox without a webhook endpoint and read the outcomes back.
Addressing rules
- Detection is by local-part only, and only on the messagebird.dev domain. bounce@yourdomain.com is a normal address.
- Only the local parts in the magic addresses table are magic. Any other address at messagebird.dev is a normal recipient. When you send from the shared onboarding domain, that address must belong to a verified workspace member.
- Matching is case-insensitive: Bounce@messagebird.dev and bounce@messagebird.dev behave identically.
- +label subaddressing is stripped before matching: bounce+signup-flow@messagebird.dev still bounces. Use labels to correlate test cases; the full address, label included, appears in your events and webhooks, so each test run can tag its own recipients.
Walkthrough: simulate a bounce, end to end
You do not need a verified sending domain. Send from onboarding@messagebird.dev, as described in Send your first email. Recognized sandbox addresses are exempt from the onboarding domain's verified-member restriction, but still count toward its daily quota.
Make sure you have a webhook endpoint subscribed to email events (see Webhooks), then send:
const msg = await bird.email.send({
from: { email: "onboarding@messagebird.dev", name: "Bird" },
to: ["bounce+signup-flow@messagebird.dev"],
subject: "Sandbox bounce test",
html: "<p>This message will hard-bounce.</p>",
tags: [{ name: "flow", value: "signup" }],
metadata: { test_run: "docs-capture-1" },
});
console.log(msg.id, msg.status); // "em_…", "accepted"msg = client.email.send(
from_={"email": "onboarding@messagebird.dev", "name": "Bird"},
to=["bounce+signup-flow@messagebird.dev"],
subject="Sandbox bounce test",
html="<p>This message will hard-bounce.</p>",
tags=[{"name": "flow", "value": "signup"}],
metadata={"test_run": "docs-capture-1"},
)
print(msg.id, msg.status)package main
import (
"context"
"fmt"
"log"
"os"
bird "github.com/messagebird/bird-sdk-go"
"github.com/messagebird/bird-sdk-go/option"
)
func main() {
client, err := bird.NewClient(option.WithAPIKey(os.Getenv("BIRD_API_KEY")))
if err != nil {
log.Fatal(err)
}
msg, err := client.Email.Send(context.Background(), bird.EmailSendParams{
From: "onboarding@messagebird.dev",
To: []string{"bounce+signup-flow@messagebird.dev"},
Subject: "Sandbox bounce test",
HTML: "<p>This message will hard-bounce.</p>",
Tags: []bird.Tag{{Name: "flow", Value: "signup"}},
Metadata: map[string]any{"test_run": "docs-capture-1"},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id, *msg.Status)
}$message = $bird->email->send(
from: 'Bird <onboarding@messagebird.dev>',
to: ['bounce+signup-flow@messagebird.dev'],
subject: 'Sandbox bounce test',
html: '<p>This message will hard-bounce.</p>',
);
echo $message->getId(), ' ', $message->getStatus();bird email send \
--from onboarding@messagebird.dev \
--to bounce+signup-flow@messagebird.dev \
--subject 'Sandbox bounce test' \
--html '<p>This message will hard-bounce.</p>' \
--tag flow=signup \
--metadata '{"test_run":"docs-capture-1"}'curl -X POST "https://us1.platform.bird.com/v1/email/messages" \
-H "Authorization: Bearer $BIRD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "onboarding@messagebird.dev",
"to": ["bounce+signup-flow@messagebird.dev"],
"subject": "Sandbox bounce test",
"html": "<p>This message will hard-bounce.</p>",
"tags": [{ "name": "flow", "value": "signup" }],
"metadata": { "test_run": "docs-capture-1" }
}'If your key starts with bk_eu1_, call https://eu1.platform.bird.com instead.
The API responds 202 Accepted with an em_* message ID, indistinguishable from a production send. That is the point: the code path you're testing is your real one. Your webhook endpoint then receives email.accepted, email.processed, and finally email.bounced. Every event echoes the send's tags and metadata (null when the send had none), and the email.bounced payload has the full bounce classification:
Code example
{
"type": "email.bounced",
"timestamp": "2026-07-23T14:51:00.362Z",
"data": {
"email_id": "em_01ky7qanhrejer0bn34v38hrxh",
"recipient_id": "er_01ky7qanhrejds4decpk83q5qq",
"workspace_id": "ws_01ky7m21hjffh9s8kq76gyb1xf",
"recipient": "bounce+signup-flow@messagebird.dev",
"recipient_role": "to",
"bounce_type": "hard",
"bounce_class": 10,
"bounce_code": "550",
"bounce_description": "5.1.1 Unknown User",
"sending_ip": null,
"tags": [{ "name": "flow", "value": "signup" }],
"metadata": { "test_run": "docs-capture-1" }
}
}Field meanings live in the events reference. No simulator flag appears anywhere in the payload: the event type and shape are exactly what a real hard bounce produces. The only thing that identifies it as simulated is the recipient address itself, so if your handler needs to special-case test traffic, key off the messagebird.dev recipient domain.
To verify that an already-suppressed recipient is never sent to, repeat the send with suppressed@messagebird.dev. Assert that you receive email.accepted, then email.rejected with rejection_reason: "recipient_suppressed". You should receive no email.processed or delivery events. This mirrors a real suppressed recipient exactly: the message is accepted, then short-circuited during processing before any send.
What the sandbox does and doesn't do
- No suppression-list writes. Simulated hard bounces and complaints do not add the recipient to your suppression list; that is what keeps the addresses reusable. Your webhook still fires (email.bounced, email.complained), so your own suppression logic is fully exercised. To test the already-suppressed rejection path, use the dedicated suppressed@ address.
- No real delivery, ever. Sandbox recipients are intercepted before the message reaches the delivery infrastructure. Nothing is transmitted, no inbox is involved, and your sending reputation is untouched.
- Request validation still applies. A sandbox send uses the normal endpoints, so schema checks, size caps, and header rules reject an invalid request as usual. What the sandbox skips is everything past the handoff: rendering and delivery behavior beyond that point aren't exercised.
- Opens and clicks aren't simulated. A magic address simulates the transmission result, and nobody opens the message, so email.opened and email.clicked come only from real mail.
- Stats include sandbox traffic. Sandbox sends count toward your workspace's aggregate stats and bounce and complaint rates. Heavy sandbox bouncing skews your dashboards while leaving your reputation unchanged.
Next steps
- Events: the full event vocabulary and per-recipient lifecycle
- Webhooks: subscribing, signature verification, and retries
- Send your first email: the onboarding-domain quickstart this walkthrough builds on
- Suppressions: how the real suppression list works