# Resend simulator

A local Resend for integration tests: send, list, reschedule and cancel email, and manage webhook subscriptions.

`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

<!-- BEGIN generated: quickstart (go run ./ingest/cmd/twindocs resend; do not edit) -->

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](https://www.rystic.ai/account/api-keys).

```bash
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](https://www.rystic.ai/docs/licensing)).

<!-- END generated: quickstart -->

### First calls

Read the newest email, send one and read it back:

```bash
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:

```python
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`):

```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 .`):

```go
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](https://www.rystic.ai/docs/intercept.md)), 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:

```bash
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](https://www.rystic.ai/docs/concepts/observing.md#live-feed) shows whether the library's requests arrive.

<!-- BEGIN generated: known-clients (go run ./ingest/cmd/twindocs resend; do not edit) -->

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 | — |

<!-- END generated: known-clients -->

## 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](#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`](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) 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](#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.

<!-- BEGIN generated: served-operations (go run ./ingest/cmd/twindocs resend; do not edit) -->

<!-- coverage-claim -->
10 routes, counted from the simulator's route table.

| 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}` |

<!-- END generated: served-operations -->

<!-- BEGIN generated: feed-effects (go run ./ingest/cmd/twindocs resend; do not edit) -->

**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`.

<!-- END generated: feed-effects -->

## Scenarios

<!-- BEGIN generated: scenarios (go run ./ingest/cmd/twindocs resend; do not edit) -->

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_studio` records, so their records quote these emails.

Boot one by name, or load one into a running simulator:

```bash
rystic run resend-twin -d --port 0 --scenario design_studio
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}'
```

<!-- END generated: scenarios -->

### What happens as the clock moves

[A scenario's timeline](https://www.rystic.ai/docs/concepts/worlds.md#a-scenarios-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`](https://www.rystic.ai/docs/control-plane-reference.md#rystic-clock) instead. Seed an email under a UUID of your own, then read it back with `GET /emails/{id}`:

```bash
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](https://www.rystic.ai/docs/concepts/worlds.md#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](https://www.rystic.ai/docs/webhooks.md#receive-signed-webhooks).

- **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](https://www.rystic.ai/docs/simulators/resend/features/scheduled-emails.md)). The events land in the log whether or not a subscription exists, and [Retention](https://www.rystic.ai/docs/concepts/observing.md#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](https://www.rystic.ai/docs/simulators/resend/features/scheduled-emails.md) 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`:

| `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](https://www.rystic.ai/docs/simulators/resend/features/sandbox-recipients.md), 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](https://www.rystic.ai/docs/webhooks.md) covers delivery and [Observing](https://www.rystic.ai/docs/concepts/observing.md) the log.

## Features

<!-- BEGIN generated: features (go run ./ingest/cmd/twindocs resend; do not edit) -->

Each feature has its own page:

- [Dashboard](https://www.rystic.ai/docs/simulators/resend/features/dashboard.md): The outbox drawn as Resend's dashboard in your browser, redrawn as your program calls the API.
- [Sandbox recipients and bounces](https://www.rystic.ai/docs/simulators/resend/features/sandbox-recipients.md): Resend's test addresses bounce, complain or deliver at send time, and a provider route bounces a sent email to any address.
- [Scheduled emails](https://www.rystic.ai/docs/simulators/resend/features/scheduled-emails.md): Emails with a scheduled_at that you reschedule or cancel, bounded by the simulator's clock rather than yours.

<!-- END generated: features -->

## 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](https://www.rystic.ai/docs/simulators/resend/features/scheduled-emails.md) checks every `scheduled_at` against it, and a scenario's timeline plays as it passes each step. [Reset and determinism](https://www.rystic.ai/docs/concepts/reset-and-determinism.md) covers reset, the clock and the RNG.

## Limitations

<!-- BEGIN generated: known-gaps (go run ./ingest/cmd/twindocs resend; do not edit) -->

**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.

<!-- gaps-claim -->
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.

**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

<!-- END generated: known-gaps -->

**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](https://www.rystic.ai/docs/simulators/resend/features/sandbox-recipients.md) 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`](https://resend.com/docs/api-reference/errors).
- **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](https://www.rystic.ai/docs/simulators/resend/features/scheduled-emails.md), [Sandbox recipients and bounces](https://www.rystic.ai/docs/simulators/resend/features/sandbox-recipients.md) and [Dashboard](https://www.rystic.ai/docs/simulators/resend/features/dashboard.md) hold the limits that belong to one feature.

Release notes: [Changelog](https://www.rystic.ai/docs/simulators/resend/changelog.md)

---

Source: https://www.rystic.ai/docs/simulators/resend · Markdown: https://www.rystic.ai/docs/simulators/resend.md
