Mock Postmark & Resend โ€” test transactional email without an account

Credit where due: Postmark and Resend are two of the very few providers that ship real test affordances. Postmark accepts the literal string POSTMARK_API_TEST as a server token โ€” no account needed โ€” and returns a genuine API response without sending anything; it also offers full sandbox servers. Resend gives you delivered@resend.dev, bounced@resend.dev and complained@resend.dev addresses (plus +label variants) that simulate outcomes end-to-end, including firing your real webhooks. If those cover your case, use them โ€” they're good.

What neither can do: fail on cue. You can't ask the real Postmark API to 500 on the third request, add two seconds of latency, or rate-limit you exactly when your retry test needs it. Resend's test addresses need an account and an API key in every CI job, and sends count against the free tier's 100/day quota. And a canned success response doesn't let your test assert what your code actually sent. This guide builds a mock for both providers in about a minute โ€” with their byte-exact response shapes, verified against the live APIs before publishing โ€” plus the failure drills they can't do. No signup, no keys.

1. What the real APIs return (verified live)

Run against the real endpoints while writing this guide โ€” this is what your client code has to parse:

# Postmark success (with the free POSTMARK_API_TEST token) โ€” HTTP 200:
{"ErrorCode":0,"Message":"Test job accepted","MessageID":"0c3c0361-4216-4d9c-85ee-a8e6fe35b114",
 "SubmittedAt":"2026-08-04T00:06:36.72041Z","To":"someone@example.com"}

# Postmark API error (missing recipient) โ€” HTTP 422, note the in-body ErrorCode:
{"ErrorCode":300,"Message":"Zero recipients specified."}

# Resend success โ€” HTTP 200:
{"id":"a2f1c9e0-โ€ฆ"}          # just an id

# Resend error โ€” {statusCode, name, message}, e.g. HTTP 401:
{"statusCode":401,"name":"missing_api_key","message":"Missing API Key"}

Two very different error dialects: Postmark multiplexes everything through ErrorCode inside a 422; Resend uses name + statusCode. If your error handling has never seen either, it's untested.

2. Build both mocks (one paste)

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

# Postmark-shaped: POST /email โ€” their real success body, uuid + timestamp templated
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"POST","path":"/email","status":200,
  "body":"{\"ErrorCode\":0,\"Message\":\"Test job accepted\",\"MessageID\":\"{{uuid}}\",\"SubmittedAt\":\"{{now}}\",\"To\":\"{{body.To}}\"}"}'

# Resend-shaped: POST /emails โ€” 200 + {"id": ""}
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"POST","path":"/emails","status":200,"body":"{\"id\":\"{{uuid}}\"}"}'

Point your client at it with the base-URL override both SDKs / your HTTP layer already support (POSTMARK_API_BASE / RESEND_BASE_URL or a plain fetch wrapper): https://mockbird.mockbird.workers.dev/m/<PID>. A Postmark-style send now behaves like the real thing โ€” {{body.To}} echoes the recipient back exactly like Postmark does:

curl -s -X POST https://mockbird.mockbird.workers.dev/m/<PID>/email \
  -H 'content-type: application/json' \
  -H 'X-Postmark-Server-Token: whatever' -d '{
  "From":"app@example.com","To":"user@example.com",
  "Subject":"Reset your password","TextBody":"..."}'
# {"ErrorCode":0,"Message":"Test job accepted",
#  "MessageID":"de40da47-ec00-4618-bc31-29b14f4a229d",
#  "SubmittedAt":"2026-08-04T00:10:01.638Z","To":"user@example.com"}

Any token header is accepted and recorded โ€” the inspector shows the exact payload and headers your code sent, so your test asserts "we emailed the right person with the right subject", not "the function didn't throw".

3. Error dialects your client must parse

Postmark's in-body ErrorCode is the classic trap: the HTTP status is 422 for every API error, and the meaning lives in the body. Flip the route into error mode (re-POSTing a route with the same method+path overwrites it), run your test, flip back:

# make /email return Postmark's exact "no recipients" error
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"POST","path":"/email","status":422,
  "body":"{\"ErrorCode\":300,\"Message\":\"Zero recipients specified.\"}"}'

curl -si -X POST $BASE/m/$PID/email -d '{"From":"a@b.c","Subject":"x"}'
# HTTP/2 422
# {"ErrorCode":300,"Message":"Zero recipients specified."}

Same trick reproduces any Resend error body ({"statusCode":403,"name":"validation_error",โ€ฆ}) on the /emails route. When you only care about the status code, skip the overwrite: ?mock_status=422 forces the status on a single request, and ?mock_seq=500,200 serves a deterministic fail-then-succeed sequence for retry logic (both verified on these exact routes; all simulation params work on custom routes).

4. Resend's 2 req/s rate limit, on cue

Resend enforces a default limit of 2 requests per second across its API โ€” the classic surprise when a batch job fans out sends. Reproduce the experience without burning real quota:

for i in 1 2 3; do curl -s -o /dev/null -w '%{http_code} ' -X POST \
  "$BASE/m/$PID/emails?mock_ratelimit=2" \
  -H 'content-type: application/json' -d '{"from":"a@b.c","to":"x@y.z","subject":"s"}'; done
# โ†’ 200 200 429

The 429 carries Retry-After and x-ratelimit-limit/remaining/reset headers (verified: retry-after: 38, x-ratelimit-remaining: 0) โ€” the same header family Resend sends โ€” so your backoff code reads real values. Honest difference: our window is per-60-seconds per client IP, not per-second; for testing "does my queue slow down and recover", that's the same drill. Slow-provider timeouts too: ?mock_delay=2000 measured 2.14s round-trip on this route.

5. Assert on a queryable outbox

For richer assertions than the inspector's last-50 window โ€” "did we email Ada exactly once, and did the bounce handler run" โ€” add an emails resource to the same project and treat sending as writing records. The full recipe (filters, X-Total-Count, PATCH-to-bounced, HMAC-signed delivery webhooks) is in the mock email API guide โ€” it works unchanged alongside the provider-shaped routes above.

Honest comparison

POSTMARK_API_TESTPostmark sandbox serverResend test addressesMockbird
Account / API key neededโœ— none โ€” genuinelyโœ” accountโœ” account + keyโœ— none
Validates your payload against their real schemaโœ” their API, for realโœ”โœ”โœ— shape is yours (generic 422 validation only)
Fires your real bounce/complaint webhooksโœ—โœ” within sandboxโœ” end-to-endsimulated (HMAC-signed, ours not theirs)
Fail on cue (429/500/sequences/latency)โœ—โœ—โœ—โœ” query params
Inspect / assert what was sentโœ— response onlyโœ” dashboardโœ” dashboard + eventsโœ” inspector + outbox, API-queryable
Quotaunlimited-ish, no accountfree plan: 100 emails/mo totalcounts against 100/day, 3,000/mo free tier10k req/day per project
Works for both providers in one placeโœ—โœ—โœ—โœ” same project

Straight answer: keep using POSTMARK_API_TEST for "is my payload valid Postmark JSON" โ€” it's free, accountless, and it's their real validator; nothing here replaces that. Use Resend's test addresses when you need the genuine webhook choreography (bounce โ†’ your handler) against your real account. Use Mockbird for everything they can't stage: deterministic failures and retries, rate-limit and timeout drills, payload assertions in CI jobs that shouldn't hold provider credentials, and pre-production environments where a real key is a liability. They compose โ€” most teams want their validator in one test and our failure drills in the next.

Notes & limits

Create yours

The paste block in section 2 is the entire setup โ€” or open the dashboard and click the routes together. The same project also gives you a GraphQL endpoint, OpenAPI + TypeScript exports, and snapshotted test fixtures.