Mock an email API โ€” test SendGrid/Mailgun-style sending without API keys

Transactional email is the integration everyone has and nobody tests. The usual options are bad: point your dev environment at the real provider (now your test suite needs a production secret and every CI run risks actually emailing someone), stub the client in code (now the HTTP layer โ€” auth headers, payload shape, error handling โ€” is exactly the part you're not testing), or skip it and find out in production that the reset-password email has been silently 401ing for a week.

This guide builds a hosted mock email API in about 60 seconds: SendGrid- and Mailgun-shaped endpoints your code can POST to over real HTTPS, an inspector that shows the exact payload sent, a queryable outbox your tests can assert on, and the failure drills no real provider will do on cue โ€” rate limits, retry sequences, slow responses. No signup, no keys. Every command below was run against production before publishing.

Scope, stated plainly: this mocks HTTP email APIs โ€” the way modern apps send via SendGrid, Mailgun, Postmark, Resend and friends. It does not speak SMTP and it does not render your HTML. If your app sends over SMTP (e.g. nodemailer's SMTP transport) or you want to eyeball how a message looks in a client, use a capture tool like Mailpit or Mailtrap โ€” more in the honest comparison.

1. Create the mock (one paste)

BASE=https://mockbird.mockbird.workers.dev
P=$(curl -s -X POST $BASE/api/projects -H 'content-type: application/json' \
     -d '{"name":"email-mock","blank":true}')
PID=$(echo $P | sed 's/.*"id": *"\([^"]*\)".*/\1/')
KEY=$(echo $P | sed 's/.*"adminKey": *"\([^"]*\)".*/\1/')

# SendGrid-shaped: 202 + empty body + X-Message-Id header
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"POST","path":"/v3/mail/send","status":202,"body":"",
  "headers":{"X-Message-Id":"{{uuid}}"}}'

# Mailgun-shaped: 200 + JSON id
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"POST","path":"/mg/messages","status":200,
  "body":"{\"id\":\"<{{uuid}}@mock.mockbird>\",\"message\":\"Queued. Thank you.\"}"}'

echo "send endpoint: $BASE/m/$PID/v3/mail/send"

Sending "an email" now behaves the way the real APIs do:

curl -si -X POST https://mockbird.mockbird.workers.dev/m/<PID>/v3/mail/send \
  -H 'content-type: application/json' -d '{
  "personalizations":[{"to":[{"email":"ada@example.com"}]}],
  "from":{"email":"noreply@myapp.dev"},
  "subject":"Welcome to MyApp",
  "content":[{"type":"text/plain","value":"Hi Ada โ€” thanks for signing up!"}]}'
HTTP/2 202
x-message-id: fddb4133-12f1-434d-919c-58ca0cbce9ca

โ€” a 202 with an empty body and a message-id header, which is SendGrid's actual success behavior (and the thing that breaks naive res.json() calls in real integrations; now you can test that your client handles it). The Mailgun-shaped route answers form posts the way Mailgun does:

curl -s -X POST https://mockbird.mockbird.workers.dev/m/<PID>/mg/messages \
  -F from='noreply@myapp.dev' -F to='ada@example.com' \
  -F subject='Welcome' -F text='Hi Ada'
# {"id":"<c22e539b-โ€ฆ@mock.mockbird>","message":"Queued. Thank you."}

Point your code at it with the base-URL override every HTTP email client has (or the fetch wrapper you already own): EMAIL_API_BASE=https://mockbird.mockbird.workers.dev/m/<PID>. Any auth header your code sends is accepted and recorded โ€” no key validation to configure.

2. Assert exactly what was sent

Every request is captured in the request inspector โ€” method, path, status and the request body. Your test can pull it and assert on the payload your code actually produced:

curl -s https://mockbird.mockbird.workers.dev/api/projects/<PID>/requests \
  -H "x-admin-key: $KEY"
{ "requests": [ {
    "method": "POST", "path": "/v3/mail/send", "status": 202,
    "body": "{\n  \"personalizations\":[{\"to\":[{\"email\":\"ada@example.com\"}]}],\n  \"from\":{\"email\":\"noreply@myapp.dev\"},\n  \"subject\":\"Welcome to MyApp\", โ€ฆ",
    โ€ฆ
} ] }

That closes the loop most email "tests" never close: not "did my function run", but "did it emit a well-formed request with the right recipient and subject". The inspector keeps the last 50 requests; write bodies are stored up to 2 KB (plenty for asserting recipients/subjects; giant HTML bodies get truncated).

3. The outbox pattern โ€” a queryable sent-mail log

For richer assertions, skip the provider-shape entirely and treat sending as writing to an emails resource. Add it to the same project:

curl -s -X POST $BASE/api/projects/$PID/resources -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "name":"emails",
  "fields":[
    {"name":"to","type":"email"},{"name":"from","type":"email"},
    {"name":"subject","type":"sentence"},{"name":"body","type":"paragraph"},
    {"name":"status","type":"oneOf","values":["queued","delivered","bounced"]}],
  "seed":0}

Your app's email service POSTs to /m/<PID>/emails in test mode; the record persists with an id. Now the whole query toolkit works on your outbox:

# did we email Ada a reset link, exactly once?
curl -s "$BASE/m/$PID/emails?to=ada@example.com&subject_like=reset"
curl -si "$BASE/m/$PID/emails?limit=1" | grep -i x-total-count   # โ†’ 1

# simulate a bounce, then assert your bounce-handling query works
curl -s -X PATCH "$BASE/m/$PID/emails/1" \
  -H 'content-type: application/json' -d '{"status":"bounced"}'
curl -s "$BASE/m/$PID/emails?status=bounced"

And with ?mock_validate=strict, malformed sends fail loudly instead of vanishing โ€” a POST missing fields gets 422 {"error":"validation_failed","fields":{"from":"required","subject":"required",โ€ฆ}} (verified above; the exact response we got in production). That's a unit test for "every code path that sends email fills in every field" with zero test code.

4. Failure drills no real provider will do on cue

All simulation params work on the provider-shaped routes and the outbox alike:

# provider rate-limits you
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  "$BASE/m/$PID/v3/mail/send?mock_status=429" -d '{}'        # โ†’ 429

# retry choreography: fail twice, then accept โ€” deterministic
for i in 1 2 3; do curl -s -o /dev/null -w '%{http_code} ' -X POST \
  "$BASE/m/$PID/v3/mail/send?mock_seq=429,429,202&mock_seq_key=t1" -d '{}'; done
# โ†’ 429 429 202

# slow provider (measured: 2.12s round-trip)
curl -s -o /dev/null -w '%{time_total}s\n' -X POST \
  "$BASE/m/$PID/v3/mail/send?mock_delay=2000" -d '{}'

The mock_seq drill is the important one: point your send-with-retry logic at it and assert it retried exactly twice, backed off, and that a 429'd send never lands in the outbox โ€” failed writes are not applied, so retries can't create duplicate "sent" records. ?mock_chaos=0.3 adds probabilistic failures on top if you want soak-style testing; see testing loading & error states for the full menu.

5. Delivery webhooks

Real providers call you back โ€” delivered, bounced, complained. With the outbox pattern you get that for free: attach a webhook to the project and every emails.created / emails.updated event POSTs to your handler, HMAC-signed with a whsec_ secret you can verify byte-for-byte. PATCHing a record to bounced (section 3) fires the exact "bounce notification" your handler should process. Full recipe with signature verification code: send test webhooks.

Honest comparison

Mailtrap sandboxMailpit / MailHog (OSS)SendGrid sandbox modeMockbird
Signup / keys neededโœ” accountโœ— (self-host)โœ” account + keyโœ— nothing
Captures real SMTPโœ”โœ”โœ—โœ— HTTP APIs only
Renders HTML / spam checksโœ” excellentโœ” web UIโœ—โœ—
Provider-exact payload validationโœ—โœ—โœ” SendGrid's ownshape is yours
Fail on demand (429s, sequences, delays)โœ—โœ—โœ—โœ” query params
Queryable outbox + delivery-webhook simulationAPI on paid-ish tiersREST APIโœ—โœ”
Reachable by CI, teammates, deployed previewsโœ”your network onlyโœ”โœ” public HTTPS
Free tier50 test emails/mo, 1 sandbox, 10 emails keptfree, but you run itneeds a SendGrid account10k req/day, no expiry

Straight answer on when to use which. If you send over SMTP, or the question is "does this email look right" โ€” HTML rendering, clients, spam scores โ€” use Mailtrap (polished, purpose-built; free tier is 50 test emails/month with 10 kept per sandbox, per their official pricing as of 2026) or self-host Mailpit (open source, great web UI + REST API, the modern MailHog). If you need SendGrid to validate your exact payload against their schema, their sandbox mode (mail_settings.sandbox_mode) does that โ€” with an account and key. Use Mockbird when the thing under test is your sending code and its failure handling โ€” payload assertions, retry/backoff, bounce flows, rate-limit UX โ€” and you want a URL any CI job or teammate can hit with zero accounts. They compose: Mailpit for "does it render", this for "does it retry".

Notes & limits

Create yours

The paste block in section 1 is the whole setup โ€” or open the dashboard and click it together. The same project also serves GraphQL, exports OpenAPI + TypeScript types, and snapshots its state for deterministic test fixtures.