Simulators
Resend simulator
resend-twin is a local, stateful simulator of the Resend email and webhook API for testing the code that sends mail. It stores the emails you send, which you read, list, reschedule and cancel, and the webhook subscriptions you manage. Requests get Resend’s responses and errors unless a line marked Differs from Resend says otherwise, and “unconfirmed” marks behavior not yet checked against Resend. The simulator never calls Resend and sends no email. It posts a webhook only to an endpoint you register, once you turn delivery on.
Quickstart
Install the CLI, sign in and boot the simulator with RYSTIC_URL pointing at it.
On Pro you sign in with an API key from your account .
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_… # your API key
export RYSTIC_URL=$(rystic run resend-twin -d --port 0 --scenario design_studio --json | jq -r .data.url)
curl -s "$RYSTIC_URL/_rystic/version"
# -> {..."product":"resend-twin"...}
On Enterprise, the licensed edition signs in with a license file in place of the key: rystic login ./rystic-license.json (licensing ).
First calls
Read the newest email, send one and read it back:
curl -s "$RYSTIC_URL/emails?limit=1" -H 'Authorization: Bearer re_placeholder' | jq -c '.data[0] | {id, subject, last_event}'
# -> {"id":"a12e15d3-48f2-4214-9506-bced42b093fc","subject":"Receipt for Fernway Therapy: Hosting, renewal","last_event":"delivered"}
curl -s "$RYSTIC_URL/emails" -H 'Authorization: Bearer re_placeholder' -H 'Content-Type: application/json' -d '{"from":"Acme <onboarding@resend.dev>","to":["delivered@resend.dev"],"subject":"Hello","html":"<p>Local test</p>"}'
# -> {"id":"28c9b732-cf71-466d-8000-000000000001"}
curl -s "$RYSTIC_URL/emails/28c9b732-cf71-466d-8000-000000000001" -H 'Authorization: Bearer re_placeholder' | jq -c '{id, object, subject, last_event}'
# -> {"id":"28c9b732-cf71-466d-8000-000000000001","object":"email","subject":"Hello","last_event":"queued"}
Connect your client
Python sets resend.api_url to $RYSTIC_URL, and any placeholder key works:
import os
import resend
resend.api_key = "re_placeholder"
resend.api_url = os.environ["RYSTIC_URL"]
email = resend.Emails.send({
"from": "Acme <onboarding@resend.dev>",
"to": ["delivered@resend.dev"],
"subject": "Hello",
"html": "<p>Local test</p>",
})
Node reads RESEND_BASE_URL from the environment, so set it to the simulator’s address before the process starts (RESEND_BASE_URL=$RYSTIC_URL node app.js):
import { Resend } from "resend";
const resend = new Resend("re_placeholder");
const { data } = await resend.emails.send({
from: "Acme <onboarding@resend.dev>",
to: ["delivered@resend.dev"],
subject: "Hello",
html: "<p>Local test</p>",
});
Go reads RESEND_BASE_URL too (RESEND_BASE_URL=$RYSTIC_URL go run .):
package main
import (
"context"
"github.com/resend/resend-go/v4"
)
func main() {
client := resend.NewClient("re_placeholder")
client.Emails.SendWithContext(context.Background(), &resend.SendEmailRequest{
From: "Acme <onboarding@resend.dev>",
To: []string{"delivered@resend.dev"},
Subject: "Hello",
Html: "<p>Local test</p>",
})
}
Under --intercept (Intercept vendor calls), calls to api.resend.com reach the simulator too, so a program you start with rystic exec needs no base URL.
Boot a second simulator behind the gateway, then read the newest email from api.resend.com with no base URL set:
rystic run resend-twin -d --port 0 --intercept --name intercepted --scenario design_studio > /dev/null
rystic exec -- curl -s 'https://api.resend.com/emails?limit=1' -H 'Authorization: Bearer re_placeholder' | jq -c '.data[0] | {id, subject, last_event}'
# -> {"id":"a12e15d3-48f2-4214-9506-bced42b093fc","subject":"Receipt for Fernway Therapy: Hosting, renewal","last_event":"delivered"}
rystic stop intercepted
While it runs, rystic exec sends the call to the simulator instead of Resend.
The live feed shows whether the library’s requests arrive.
Each client’s quickstart was run against this simulator in each way it connects, with the vendor’s own hosts blocked.
| Client | Version | Kind | Works with | Fails with | Checked | Known gap |
|---|---|---|---|---|---|---|
github.com/resend/resend-go/v4 (Go) | v4.12.1 | sdk | base URL | — | 2026-11-10 | — |
resend (npm) | v6.37.0 | sdk | base URL | — | 2026-11-10 | — |
resend (PyPI) | v2.53.1 | sdk | base URL | — | 2026-11-10 | — |
Auth
The simulator reads your key where Resend does, as Authorization: Bearer <key>. It stores no Resend keys, so any non-empty Bearer key works and none is ever refused as unknown or revoked. Every API route checks the header before it reads or writes anything, and refuses a missing or malformed key as follows:
| You send | An API route returns |
|---|---|
no Authorization header | 401 {"message":"Missing API Key","name":"missing_api_key","statusCode":401} |
an empty Bearer key, or another scheme such as Basic abc | 400 {"message":"API key is invalid","name":"validation_error","statusCode":400} |
API version
Resend’s API has no version, so the simulator serves one unversioned API and sends no version header. A request that sends a version header of its own gets the same response, so there is no version to pin.
Errors and limits
Error format
A Resend error body is flat: statusCode, name and message, with no wrapping object. A refused send returns 422 with a name such as validation_error or missing_required_field. An unknown email or webhook returns 404 not_found. A path the simulator doesn’t serve, or a method a path doesn’t take, returns 405 method_not_allowed.
Rate limits
All keys share one fixed window of 10 requests per second on the wall clock. Every data-plane response carries ratelimit-policy, ratelimit-limit, ratelimit-remaining and ratelimit-reset, errors and 405s included. The eleventh request in a window returns 429 rate_limit_exceeded with retry-after. Control routes are exempt, and reset restores the allowance.
Daily and monthly quotas are not enforced (Limitations).
Pagination
GET /emails and GET /webhooks page with limit, after and before, newest first, each cursor being a record’s id. limit defaults to 20 and takes 1 to 100. A page is {"object": "list", "has_more": …, "data": […]}.
| You send | The simulator returns |
|---|---|
a limit outside 1 to 100, a cursor that is not a UUID, or both cursors | 422 validation_error |
| a well-formed cursor naming no record | 404 not_found |
/_rystic/page-cap caps limit; name the route as in GET /emails.
Idempotency
A POST /emails that carries an Idempotency-Key header is replayed as Resend replays it, so your library’s automatic retries are safe. Only POST /emails takes a key.
| You send | The simulator returns |
|---|---|
| the same key and the same body | the first response, and creates nothing new |
| the same key with another body | 409 invalid_idempotent_request |
| an empty key, or one longer than 256 characters | 400 invalid_idempotency_key |
A key never expires (Limitations). The same JSON written another way, with other spacing or key order, returns 409 invalid_idempotent_request (unconfirmed). Concurrent requests under one key are unconfirmed.
Coverage
The simulator covers Resend’s email and webhook routes: sending, listing, reading, rescheduling and canceling emails, plus managing webhook subscriptions. Batch send, attachment retrieval, domains, API keys, contacts, audiences, broadcasts, templates and receiving are out of scope, and the API has no email-delete operation. A send whose attachments each have content or path succeeds, but the simulator never fetches or delivers them.
10 routes, counted from the simulator’s route table.
Show all operations
| Resource | Operations |
|---|---|
| Email (5) | GET, POST /emails; GET, PATCH /emails/{email_id}; POST /emails/{email_id}/cancel |
| Webhook (5) | GET, POST /webhooks; GET, PATCH, DELETE /webhooks/{webhook_id} |
Feed effects. Each operation below can put an effect event on the event feed (/_rystic/events) for each event type beside it, when what the call does produces that event. Any other call puts only its request event there.
| Operation | Event types |
|---|---|
POST /emails | email.bounced, email.complained, email.delivered, email.scheduled, email.sent; type set per call, by effect: eff_email_dispatch |
These types also reach the feed from the simulator’s own activity rather than from a call, such as a scheduled send, a clock advance or a control-plane action: email.bounced.
Scenarios
The simulator ships 1 scenario; the quickstart boots design_studio.
| Scenario | What it is for | What it seeds | Clock |
|---|---|---|---|
design_studio (default) | Website Design Contractors’ outbox | email 595 | 2026-03-01T00:00:00Z |
What each scenario holds:
design_studio:- 595 emails sent from November 2024 to February 2026: 195 invoices, 325 receipts, 67 welcome and launch notes from the project managers, 1 declined-card notice, 1 refund notice and 6 overdue reminders (3 each to Pickford Law and Saltmarsh Brewing).
- One receipt bounced (Bluefin Seafood’s “Receipt for Bluefin Seafood: Care Plus plan, January 2026”, sent to a departed contact) and was resent to their replacement.
- Its ids match the other simulators’
design_studiorecords, so their records quote these emails.
Boot one by name, or load one into a running simulator:
rystic run resend-twin -d --port 0 --scenario design_studio
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}'
What happens as the clock moves
A scenario’s timeline plays as the clock passes each step, and design_studio’s runs these:
| After the start | The studio |
|---|---|
| 30 minutes | registers a webhook |
| 14 hours | sends Copperline Coffee’s March invoice |
| 24 hours | schedules an overdue reminder to Pickford Law for 2026-03-15 |
| 48 hours | moves the reminder to 2026-03-16 |
| 72 hours | cancels the reminder |
Its calls take the first ids the sequences mint.
What the scenarios do not hold
- No webhook subscription until the timeline registers one, so register yours with
POST /webhooks.
Seeding state
A seed takes three registers:
| Register | Keyed by |
|---|---|
state_email | the email’s id, a UUID, as Resend requires on a read |
state_webhook | the webhook’s id, a UUID. A seeded webhook needs a signing_secret of whsec_ plus base64, or it gets no deliveries. |
state_email_idempotency | idempotency_key, the value of the Idempotency-Key header a POST /emails sent, with surrounding spaces trimmed |
A seed refuses state_clock: set the clock with POST /_rystic/clock instead. Seed an email under a UUID of your own, then read it back with GET /emails/{id}:
curl -s -X POST "$RYSTIC_URL/_rystic/seed" -d '{"state_email": {"11111111-1111-4111-8111-111111111111": {"id": "11111111-1111-4111-8111-111111111111", "object": "email", "from": "Acme <onboarding@resend.dev>", "to": ["dev@example.com"], "subject": "Seeded", "last_event": "delivered", "created_at": "2026-01-01 00:00:00.000000+00"}}}'
curl -s "$RYSTIC_URL/emails/11111111-1111-4111-8111-111111111111" -H 'Authorization: Bearer re_placeholder' | jq -c '{id, subject, last_event}'
# -> {"id":"11111111-1111-4111-8111-111111111111","subject":"Seeded","last_event":"delivered"}
Seeding covers the seed route’s empty 204, the 400s it returns for what it refuses and seed files.
Webhooks
Every POST /emails records its events, in order, on GET /_rystic/egress, and they reach your webhooks once delivery is on.
-
The log. An email sent now logs the SMTP handoff, then its webhook events. A send with
scheduled_atlogsemail.scheduledand no handoff (Scheduled emails). The events land in the log whether or not a subscription exists, and Retention bounds it. -
Delivery. Each event is POSTed to every enabled webhook whose
eventsnames it, whether you registered it throughPOST /webhooksor seeded it. It is signed with that webhook’ssigning_secretin Svix headers:svix-id,svix-timestampandsvix-signature. The SMTP handoff is never POSTed. The event’s owncreated_atis the simulator’s clock. -
Timestamps. Every event’s
created_atanddata.created_atare ISO 8601 in UTC with three fraction digits, as in2026-03-01T00:00:00.001Zor2026-03-01T00:00:00.000Z,email.scheduled’s included. A read of the email keeps the form Resend’s reads use, as in2026-03-01 00:00:00.001000+00. Scheduled emails shows both forms side by side. -
No retries. Each webhook gets one attempt, and
deliveryin the log lists each attempt by webhook id.Differs from Resend: Resend retries a failed delivery on a schedule (unconfirmed).
-
Suppression handlers. A webhook subscribed to
email.bouncedoremail.complainedreceives one when you send to a sandbox recipient, and one subscribed toemail.bouncedreceives one whenPOST /_bouncebounces an email to any address.
Each log entry names its effect and its type, and carries a payload:
effect | type | payload |
|---|---|---|
eff_email_dispatch | smtp-delivery/<recipients> | the message as it would be handed to SMTP: from, to, cc, bcc, reply_to, subject, html, text, headers, attachments and message_id |
eff_email_webhook_events | email.sent or email.scheduled; for a sandbox recipient, also email.delivered, email.bounced or email.complained | the webhook event Resend would deliver: type, created_at and data |
eff_world_email_bounced | email.bounced, from POST /_bounce | the webhook event, with data.bounce |
The email.sent, email.delivered, email.bounced and email.complained bodies match Resend’s, apart from ids and timestamps.
Differs from Resend: every event of a send has the send instant as its created_at, where Resend stamps each later event a little later.
Differs from Resend: a webhook the simulator stamps has a created_at in milliseconds, as in 2026-01-01 00:00:00.001+00, where Resend’s has microseconds.
Webhooks covers delivery and Observing the log.
Features
Each feature has its own page:
- Dashboard: The outbox drawn as Resend’s dashboard in your browser, redrawn as your program calls the API.
- Sandbox recipients and bounces: Resend’s test addresses bounce, complain or deliver at send time, and a provider route bounces a sent email to any address.
- Scheduled emails: Emails with a scheduled_at that you reschedule or cancel, bounded by the simulator’s clock rather than yours.
Determinism and reset
The first email you send after a reset is 28c9b732-cf71-466d-8000-000000000001, and the trailing digits count up; an email and a webhook never share an id. Each send, reschedule, webhook registration and POST /_bounce moves the clock one millisecond. So do a webhook list and a read of an email or webhook seeded without a created_at. svix-timestamp follows the wall clock, not the simulator’s, so pinning the clock never makes Svix reject a delivery.
Little depends on the clock: Scheduled emails checks every scheduled_at against it, and a scenario’s timeline plays as it passes each step. Reset and determinism covers reset, the clock and the RNG.
Limitations
Known issues.
- An
Idempotency-Keynever expires, where Resend expires it after 24 hours. - Daily and monthly sending quotas and their headers are not enforced.
Beyond the known issues, 87 of the 97 operations in the published spec are not served, 27 documented responses have no test yet and 47 behaviors are not modeled. The details follow.
Show all limitations
Not served. 87 of the 97 operations in the published spec are not served.
- Emails, 5 of 8 served:
POST /emails/batch,GET /emails/{email_id}/attachments,GET /emails/{email_id}/attachments/{attachment_id} - Not served at all: API Keys (3), Audiences (4), Automations (8), Broadcasts (6), Contact Properties (5), Contacts (13), Domains (9), Events (6), Logs (2), OAuth (2), Receiving Emails (4), Segments (4), Suppressions (6), Templates (7), Topics (5)
Not tested. 64 tests compare the simulator with the real service. 27 documented responses have no test yet, and 9 of them need a credential our test account doesn’t hold (401 ×8, 403 ×1). The other 18:
| Operation | Status | Why |
|---|---|---|
DELETE /webhooks/{webhook_id} | 422 | no known request reaches it |
GET /emails/{email_id} | 422 | no known request reaches it |
GET /emails/{email_id} | 429 | no request a client sends provokes it |
GET /webhooks/{webhook_id} | 422 | no known request reaches it |
GET /webhooks/{webhook_id} | 429 | no request a client sends provokes it |
PATCH /emails/{email_id} | 400 | a well-formed request does not provoke it |
PATCH /emails/{email_id} | 422 | no known request reaches it (2 cases) |
PATCH /emails/{email_id} | 429 | no request a client sends provokes it |
PATCH /webhooks/{webhook_id} | 400 | a well-formed request does not provoke it |
PATCH /webhooks/{webhook_id} | 429 | no request a client sends provokes it |
POST /emails | 400 | a well-formed request does not provoke it (2 cases) |
POST /emails | 409 | no known request reaches it |
POST /emails | 429 | no request a client sends provokes it |
POST /emails/{email_id}/cancel | 422 | no known request reaches it |
POST /webhooks | 400 | a well-formed request does not provoke it |
POST /webhooks | 429 | no request a client sends provokes it |
Not modeled. Behavior the simulator does not model, by operation:
- Residual unknowns: whether the all-zero (nil) uuid passes the format validator and reaches the existence 404, and the validator’s exact boundary (uuid version/variant bits). (
DELETE /webhooks/{webhook_id},PATCH /emails/{email_id}) - The spec applies a global bearerAuth security requirement, so a missing or invalid API key would be a 401 (Resend’s ‘missing_api_key’). (
DELETE /webhooks/{webhook_id}) - Scope of the listing: whether the list includes canceled and scheduled emails, and whether it is scoped by the API key’s permissions (full_access vs sending_access), is undocumented for this operation. (
GET /emails) - Cross-account isolation: whether an email id owned by another account yields 404 or 401/403. (
GET /emails/{email_id}) - Whether a read of a scheduled email includes a scheduled_at field. (
GET /emails/{email_id}) - Whether the list is scoped per API key or per account, and whether a restricted key sees a filtered subset rather than a 401. (
GET /webhooks) - Cross-account isolation: whether a webhook id owned by another Resend account yields 404 or 401/403. (
GET /webhooks/{webhook_id}) - The exact created_at serialization contract. (
GET /webhooks/{webhook_id}) - Whether the endpoint’s delivery health (last delivery status, failure counters, auto-disable after repeated failures) surfaces anywhere on this read. GetWebhookResponse declares no such fields, so none are modeled. (
GET /webhooks/{webhook_id}) - Whether the events array can actually read back as null in practice. (
GET /webhooks/{webhook_id}) - Whether PATCH accepts any field other than scheduled_at, and whether unknown body fields are rejected or ignored. (
PATCH /emails/{email_id}) - Whether an Idempotency-Key header is honored on PATCH (it is documented only on POST /emails). (
PATCH /emails/{email_id}) - Whether rescheduling to a time in the PAST is rejected or coerced to an immediate send — the spec sets no lower bound on scheduled_at. (
PATCH /emails/{email_id}) - Whether the 200 body echoes the request or returns an {object, id} acknowledgement. (
PATCH /emails/{email_id}) - Whether there is a maximum scheduling horizon (an upper bound on how far ahead scheduled_at may be set). (
PATCH /emails/{email_id}) - Cross-account isolation: whether a webhook id owned by another account yields 404 or 401/403. (
PATCH /webhooks/{webhook_id}) - Whether a changed endpoint triggers any re-verification or challenge request to the new URL before the update is accepted. (
PATCH /webhooks/{webhook_id}) - Whether an entirely empty body {} is a 200 no-op or a validation rejection. (
PATCH /webhooks/{webhook_id}) - Whether disabling a webhook drops or buffers events that fire while it is disabled. (
PATCH /webhooks/{webhook_id}) - Whether the strings in events are validated against a fixed catalog of event types (e.g. ‘email.sent’), and what an unrecognized event type returns. (
PATCH /webhooks/{webhook_id}) - … and 27 more
By design.
-
Delivery states do not advance. Moving the clock delivers nothing.
Differs from Resend: delivery, open, click and bounce states do not advance on their own. Only a sandbox recipient bounces or complains, at send time, or
POST /_bouncebounces a sent email. -
Plain text is derived from HTML. A send with
htmland notextstores atextthe simulator derives fromhtml.Differs from Resend: that
textcan differ from the one Resend derives (unconfirmed). -
Sender domains are not verified. A local send can succeed with an unverified sender domain, such as
gmail.com, so local success does not establish that a sender is ready for production.Differs from Resend: Resend rejects unverified sender domains with
403 validation_error. -
Validation coverage is bounded by measurements. The measured attachment and custom-header refusals are covered. Other attachment and header shapes are unconfirmed.
Differs from Resend: an array body on
POST /emailsreturns 422 every time. Resend returned 400 or 422 for the same array across runs.
Scheduled emails, Sandbox recipients and bounces and Dashboard hold the limits that belong to one feature.
Release notes: Changelog