Skip to Content
SimulatorsResendOverview

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.

ClientVersionKindWorks withFails withCheckedKnown gap
github.com/resend/resend-go/v4 (Go)v4.12.1sdkbase URL—2026-11-10—
resend (npm)v6.37.0sdkbase URL—2026-11-10—
resend (PyPI)v2.53.1sdkbase 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 sendAn API route returns
no Authorization header401 {"message":"Missing API Key","name":"missing_api_key","statusCode":401}
an empty Bearer key, or another scheme such as Basic abc400 {"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 sendThe simulator returns
a limit outside 1 to 100, a cursor that is not a UUID, or both cursors422 validation_error
a well-formed cursor naming no record404 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 sendThe simulator returns
the same key and the same bodythe first response, and creates nothing new
the same key with another body409 invalid_idempotent_request
an empty key, or one longer than 256 characters400 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
ResourceOperations
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.

OperationEvent types
POST /emailsemail.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.

ScenarioWhat it is forWhat it seedsClock
design_studio (default)Website Design Contractors’ outboxemail 5952026-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_studio records, 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 startThe studio
30 minutesregisters a webhook
14 hourssends Copperline Coffee’s March invoice
24 hoursschedules an overdue reminder to Pickford Law for 2026-03-15
48 hoursmoves the reminder to 2026-03-16
72 hourscancels 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:

RegisterKeyed by
state_emailthe email’s id, a UUID, as Resend requires on a read
state_webhookthe webhook’s id, a UUID. A seeded webhook needs a signing_secret of whsec_ plus base64, or it gets no deliveries.
state_email_idempotencyidempotency_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_at logs email.scheduled and 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 events names it, whether you registered it through POST /webhooks or seeded it. It is signed with that webhook’s signing_secret in Svix headers: svix-id, svix-timestamp and svix-signature. The SMTP handoff is never POSTed. The event’s own created_at is the simulator’s clock.

  • Timestamps. Every event’s created_at and data.created_at are ISO 8601 in UTC with three fraction digits, as in 2026-03-01T00:00:00.001Z or 2026-03-01T00:00:00.000Z, email.scheduled’s included. A read of the email keeps the form Resend’s reads use, as in 2026-03-01 00:00:00.001000+00. Scheduled emails shows both forms side by side.

  • No retries. Each webhook gets one attempt, and delivery in 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.bounced or email.complained receives one when you send to a sandbox recipient, and one subscribed to email.bounced receives one when POST /_bounce bounces an email to any address.

Each log entry names its effect and its type, and carries a payload:

effecttypepayload
eff_email_dispatchsmtp-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_eventsemail.sent or email.scheduled; for a sandbox recipient, also email.delivered, email.bounced or email.complainedthe webhook event Resend would deliver: type, created_at and data
eff_world_email_bouncedemail.bounced, from POST /_bouncethe 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-Key never 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:

OperationStatusWhy
DELETE /webhooks/{webhook_id}422no known request reaches it
GET /emails/{email_id}422no known request reaches it
GET /emails/{email_id}429no request a client sends provokes it
GET /webhooks/{webhook_id}422no known request reaches it
GET /webhooks/{webhook_id}429no request a client sends provokes it
PATCH /emails/{email_id}400a well-formed request does not provoke it
PATCH /emails/{email_id}422no known request reaches it (2 cases)
PATCH /emails/{email_id}429no request a client sends provokes it
PATCH /webhooks/{webhook_id}400a well-formed request does not provoke it
PATCH /webhooks/{webhook_id}429no request a client sends provokes it
POST /emails400a well-formed request does not provoke it (2 cases)
POST /emails409no known request reaches it
POST /emails429no request a client sends provokes it
POST /emails/{email_id}/cancel422no known request reaches it
POST /webhooks400a well-formed request does not provoke it
POST /webhooks429no 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 /_bounce bounces a sent email.

  • Plain text is derived from HTML. A send with html and no text stores a text the simulator derives from html.

    Differs from Resend: that text can 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 /emails returns 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

Last updated on