# Resend compatibility reference

> What the compatibility layer mirrors of Resend's API, path by path, and where its shapes, errors, events and webhooks differ from Resend's.

Source: https://docs.minket.com/compatibility/
Updated: 2026-10-29

The compatibility layer serves Resend's paths at the root of `https://api.minket.com`, hyphens included where Resend has them (`/contact-properties`, `/api-keys`), and takes the same keys as the native API. Bodies follow the `resend` SDK's type declarations (6.x) field for field. Contract tests run the official SDK at 6.10.0, 6.12.4, 6.17.1, 6.22.1 and the latest release against the API over HTTP and check the `{ data, error }` it hands back, error names and statuses included; a call marked with a version below needs that SDK version or later. [Move a project from Resend](https://docs.minket.com/migrate/) has the steps.

## Paths

| Paths | SDK calls | Scope |
| --- | --- | --- |
| `POST /emails`, `POST /emails/batch`, `PATCH /emails/:id`, `POST /emails/:id/cancel` | `emails.send`, `batch.send`, `emails.update`, `emails.cancel` | `sending_access` |
| `POST /emails/:id/share` | `emails.share` (6.22.1), for a sent or a received email | `full_access` |
| `GET /emails`, `GET /emails/:id`, `GET /emails/:id/attachments`, `GET /emails/:id/attachments/:attachmentId`, `GET /emails/metrics` | `emails.list`, `emails.get`, `emails.attachments.list`, `emails.attachments.get`, `emails.metrics` (6.22.1) | `full_access` |
| `GET /emails/receiving`, `GET /emails/receiving/:id`, `GET /emails/receiving/:id/attachments`, `GET /emails/receiving/:id/attachments/:attachmentId` | `emails.receiving.list`, `emails.receiving.get`, `emails.receiving.attachments.*` | `inbound_read` |
| `POST /emails/receiving/:id/forward` | forwarding a received email | `full_access` |
| `POST /domains`, `GET /domains`, `GET /domains/:id`, `PATCH /domains/:id`, `DELETE /domains/:id`, `POST /domains/:id/verify` | `domains.*` | `full_access` |
| `POST /webhooks`, `GET /webhooks`, `GET /webhooks/:id`, `PATCH /webhooks/:id`, `DELETE /webhooks/:id`, `POST /webhooks/:id/signing-secret/rotate`, `GET /webhooks/:id/events`, `GET /webhooks/:id/events/:eventId`, `POST /webhooks/:id/events/:eventId/replay`, `GET /webhooks/:id/events/:eventId/attempts` | `webhooks.*`, `webhooks.events.*` | `full_access` |
| `/contacts`, `/contacts/:id`, `/contacts/imports`, `/contacts/:id/topics`, `/contacts/:id/segments`, `/contacts/:id/segments/:segmentId`, `/contact-properties`, `/segments`, `/audiences` (legacy), `/audiences/:id/contacts`, `/topics` | contacts, properties, segments, audiences, topics | `full_access` |
| `/templates`, `/templates/:id`, `/templates/:id/publish`, `/templates/:id/duplicate` | `templates.*` | `full_access` |
| `/broadcasts`, `/broadcasts/:id`, `/broadcasts/:id/send`, `/broadcasts/:id/cancel`, `/broadcasts/:id/duplicate`, `/broadcasts/:id/recipients`, `/broadcasts/:id/clicked-links` | `broadcasts.*` | `full_access` |
| `/events`, `/events/send`, `/events/:id`, `/automations`, `/automations/:id`, `/automations/:id/stop`, `/automations/:id/duplicate`, `/automations/:id/runs`, `/automations/:id/runs/:runId` | events and automations | `full_access` |
| `/api-keys`, `/api-keys/:id`, `/logs`, `/logs/:id` | `apiKeys.*`, logs | `full_access` |

Calls that are not mirrored answer what Resend answers for an endpoint it does not serve, `404 not_found` with "The requested endpoint does not exist.", whatever the method: `domains.claims.*`, `suppressions.*` (the native [suppressions](https://docs.minket.com/sending/#suppressions) cover them), `oauthGrants.*`, `inboxes.*` and `usage.get`. A wrong method on a mirrored path answers `405 method_not_allowed` with an `allow` header, and a path id that is not a UUID answers `404 not_found`.

## Emails

- `POST /emails` takes `from`, `to`, `cc`, `bcc`, `reply_to`, `subject`, `html`, `text`, `headers`, `attachments`, `tags`, `scheduled_at`, `topic_id` and `template`, and returns `{ "id": "…" }`. The native extras (`stream`, `via`, `tracking`, `idempotency_key` in the body, attachments by id) are not taken. Attachments carry `content` (a base64 string, a serialized Buffer or a byte array), `filename`, `path`, `content_type` and `content_id`. An `Idempotency-Key` header is honoured for 24 hours. `region` is accepted and ignored.
- `POST /emails/batch` takes up to 100 emails, `scheduled_at` and attachments included (Resend's batch takes no attachments). With the default `x-batch-validation: strict`, one invalid item rejects the batch and nothing is stored; with `permissive` the valid items are sent and the rest are listed in `errors` as `{ "index": 0, "message": "…" }`. The answer is `{ "data": [{ "id": "…" }], "errors": [...] }`.
- `GET /emails/:id` returns `object`, `id`, `to`, `from`, `created_at`, `subject`, `bcc`, `cc`, `reply_to`, `html`, `text`, `last_event`, `message_id`, `tags`, `topic_id` and `scheduled_at`. `last_event` is the email's [status](https://docs.minket.com/events/#email-status); `message_id` is an empty string until SES reports the send.
- `PATCH /emails/:id` takes `{ "scheduled_at": "…" }`; it and `POST /emails/:id/cancel` return `{ "object": "email", "id": "…" }`.
- `emails.share` and `emails.metrics` answer with the results of [sharing](https://docs.minket.com/sending/#share-an-email) and [metrics](https://docs.minket.com/sending/#metrics).
- `/logs` lists the requests made with the project's keys in Resend's shape, with `request_body` and `response_body` always null: bodies are never stored.
- A `sending_access` key sends, reschedules and cancels; reading mail needs a full-access key (`401 restricted_api_key`).

## Errors

Errors are Resend's flat body, with Resend's phrasing for validation messages:

```json
{ "statusCode": 422, "message": "Missing `to` field.", "name": "validation_error" }
```

Each code reports this name; codes Resend does not have take the closest of Resend's:

| Code | Status | Name on the compatibility layer |
| --- | --- | --- |
| `invalid_idempotency_key` | 400 | `invalid_idempotency_key` |
| `invalid_sign_in_code` | 400 | `validation_error` |
| `missing_api_key` | 401 | `missing_api_key` |
| `restricted_api_key` | 401 | `restricted_api_key` |
| `unauthorized` | 401 | `missing_api_key` |
| `account_blocked` | 403 | `invalid_access` |
| `domain_authentication_failed` | 403 | `invalid_from_address` |
| `domain_not_verified` | 403 | `invalid_from_address` |
| `forbidden` | 403 | `invalid_access` |
| `invalid_access` | 403 | `invalid_access` |
| `invalid_api_key` | 403 | `invalid_api_key` |
| `invalid_from_address` | 403 | `invalid_from_address` |
| `organization_suspended` | 403 | `invalid_access` |
| `signup_closed` | 403 | `invalid_access` |
| `test_mode_only` | 403 | `restricted_api_key` |
| `not_found` | 404 | `not_found` |
| `method_not_allowed` | 405 | `method_not_allowed` |
| `concurrent_idempotent_requests` | 409 | `concurrent_idempotent_requests` |
| `conflict` | 409 | `validation_error` |
| `invalid_state` | 409 | `validation_error` |
| `mailbox_unavailable` | 409 | `application_error` |
| `payload_too_large` | 413 | `validation_error` |
| `unsupported_media_type` | 415 | `validation_error` |
| `content_blocked` | 422 | `validation_error` |
| `invalid_attachment` | 422 | `invalid_attachment` |
| `invalid_idempotent_request` | 422 | `invalid_idempotent_request` |
| `invalid_parameter` | 422 | `invalid_parameter` |
| `invalid_region` | 422 | `invalid_region` |
| `missing_required_field` | 422 | `missing_required_field` |
| `stream_rule_violation` | 422 | `validation_error` |
| `validation_error` | 422 | `validation_error` |
| `daily_quota_exceeded` | 429 | `daily_quota_exceeded` |
| `monthly_quota_exceeded` | 429 | `monthly_quota_exceeded` |
| `rate_limit_exceeded` | 429 | `rate_limit_exceeded` |
| `security_error` | 451 | `security_error` |
| `application_error` | 500 | `application_error` |
| `internal_server_error` | 500 | `internal_server_error` |
| `not_implemented` | 501 | `application_error` |
| `outcome_unknown` | 502 | `application_error` |
| `upstream_unavailable` | 502 | `application_error` |
| `sending_paused` | 503 | `application_error` |

## API keys

`apiKeys.create`, `list`, `update` (6.22.1) and `remove` work against `/api-keys`. The new key takes the calling key's project, environment and mode; `permission` is `full_access` (the default) or `sending_access`, and `domain_id` restricts a `sending_access` key to a domain. The calling key must be a live full-access key. `update` changes only the name and answers `{ object: "api_key", id }`; `remove` answers `{ object: "api_key", id, deleted: true }`.

## Domains

- `POST /domains` takes `name`, `region` (`us-east-1` only; other regions answer `invalid_region`), `custom_return_path` (the MAIL FROM label), `capabilities` (`sending`, `receiving`), `open_tracking`, `click_tracking`, `tls` and `tracking_subdomain`, and returns the domain with its `records`.
- `records` are `SPF` (the MAIL FROM MX and TXT), `DKIM`, `Receiving`, `Tracking` and `TrackingCAA` entries, each with `record`, `name` relative to the registrable domain (`bounce.mail` under `acme.example`), `value`, `type`, `ttl` (`Auto`), `status` and, for MX, `priority`. DMARC is not one of Resend's record kinds; read it from `GET /v1/domains/{id}` or the console.
- `verify` checks now and answers `{ object, id }`; `remove` answers `{ object, id, deleted: true }`.
- Domains report the regions they are verified in: `us-east-1`, plus `us-west-2` with failover.
- The DKIM selectors are SES's, not Resend's, and the custom MAIL FROM is `bounce.<domain>` where Resend uses `send.<domain>`, so both sets of records coexist and a domain stays valid in Resend during a migration.

## Templates

`templates.create`, `list`, `get`, `update`, `remove`, `publish` and `duplicate` work, including the chained `.publish()`. Resend's variables `{{{NAME}}}` and `{{{NAME|fallback}}}` work in any case and are escaped like `{{name}}` ([Variables](https://docs.minket.com/templates/#variables)); a variable's fallback is `fallbackValue`.

```ts
await resend.templates
  .create({
    name: "Order confirmation",
    alias: "order-confirmation",
    subject: "Order {{{ORDER_ID}}}",
    html: "<p>Thanks for buying {{{PRODUCT}}}.</p>",
    variables: [
      { key: "PRODUCT", type: "string", fallbackValue: "your order" },
      { key: "ORDER_ID", type: "string" },
    ],
  })
  .publish();

await resend.emails.send({
  from: "Acme <shop@mail.acme.example>",
  to: "jane@example.com",
  template: { id: "order-confirmation", variables: { PRODUCT: "Desk lamp", ORDER_ID: "10452" } },
});
```

## Webhooks

- `POST /webhooks` takes `{ "endpoint", "events" }` and returns `{ "object": "webhook", "id", "signing_secret" }`. `events` is required and limited to the types marked below. The endpoint is created in the key's environment and mode, with a new signing secret.
- `GET /webhooks` and `GET /webhooks/:id` return `endpoint`, `events`, `created_at` and `status` (`enabled`, or `disabled` for an endpoint disabled by hand or automatically); `GET /webhooks/:id` includes `signing_secret`.
- `PATCH /webhooks/:id` takes `endpoint`, `events` and `status`; `DELETE /webhooks/:id` answers `{ "object": "webhook", "id", "deleted": true }`.
- `rotateSigningSecret` (6.34): `POST /webhooks/:id/signing-secret/rotate` answers `{ "object": "webhook", "id", "signing_secret" }`, and the previous secret keeps signing for 24 hours.
- `events.*` (6.34): `GET /webhooks/:id/events` lists the events sent to the endpoint, newest first, as `{ "id", "type", "created_at", "status" }`, paged with `limit` and `after`. The `id` is the `svix-id` the endpoint received (`msg_…`). `status` is `pending` before the first attempt, `attempting` while one runs or a retry waits, `success` once an attempt got a `2xx`, and `failed` when none did and no more will run. `GET /webhooks/:id/events/:eventId` adds `next_attempt_at` and `payload`, the body as sent; `.../attempts` lists its attempts as `{ "id", "http_status_code", "response", "sent_at" }`, with `http_status_code` `0` when no response came back. `POST /webhooks/:id/events/:eventId/replay` sends the event once more now, with the same body and `svix-id`, and answers `{ "object": "webhook_event", "id" }`.

These read the same deliveries as the [native routes](https://docs.minket.com/webhooks/), grouped by event where those list each delivery. Endpoints made on the compatibility layer subscribe to Resend's event types only:

| Event | On compatibility endpoints |
| --- | --- |
| `email.sent` | yes |
| `email.scheduled` | yes |
| `email.delivered` | yes |
| `email.delivery_delayed` | yes |
| `email.complained` | yes |
| `email.bounced` | yes |
| `email.opened` | yes |
| `email.clicked` | yes |
| `email.received` | yes |
| `email.failed` | yes |
| `email.suppressed` | yes |
| `email.canceled` | no |
| `contact.created` | yes |
| `contact.updated` | yes |
| `contact.deleted` | yes |
| `domain.created` | yes |
| `domain.updated` | yes |
| `domain.deleted` | yes |
| `broadcast.started` | no |
| `broadcast.completed` | no |
| `automation.completed` | no |
| `automation.failed` | no |

Deliveries are signed and shaped as Resend's ([Verify signatures](https://docs.minket.com/webhooks/#verify-signatures)), so `resend.webhooks.verify` keeps working with the new secret:

```ts
const event = resend.webhooks.verify({
  payload: await request.text(),
  headers: {
    id: request.headers.get("svix-id") ?? "",
    timestamp: request.headers.get("svix-timestamp") ?? "",
    signature: request.headers.get("svix-signature") ?? "",
  },
  webhookSecret: process.env.MINKET_WEBHOOK_SECRET ?? "",
});
```

## Received mail

- `GET /emails/receiving` and `GET /emails/receiving/:id` (with `?html_format=data_uri|cid`) return Resend's receiving email: `object`, `id`, `to`, `from`, `created_at`, `subject`, `bcc`, `cc`, `reply_to`, `received_for` (the envelope recipients), `html`, `text`, `headers`, `message_id`, `raw` and `attachments`. The single email also has `authentication`: `spf`, `dkim` and `dmarc`, each `pass`, `fail`, `gray`, `processing_failed` or `unknown`, from SES's [verdicts](https://docs.minket.com/inbound/#verdicts). `gray` is SPF `none`, `softfail` or `neutral`, DKIM unsigned or signed only by a domain outside the From domain's organizational domain, and DMARC `none`. Resend also says `gray` for DMARC under `p=none`, which SES's header does not show, so such mail reads `pass` here.
- `GET /emails/receiving/:id/attachments` and `/attachments/:attachmentId` return `download_url` and `expires_at`.
- The `email.received` webhook carries the parsed content under `data.content` (`text`, `html`, `truncated`) and the verdicts. There is no `data.text`, `data.html` or `data.headers`, so a handler that reads those reads `data.content` instead, or calls `emails.receiving.get`.
- `resend.emails.receiving.forward()` runs in the SDK: it downloads `raw.download_url` and sends a new email with `POST /emails`, so it needs a key that can send and a `from` on a verified domain, and it is an ordinary send, not a platform forward (no confirmation, no ARC). `POST /emails/receiving/:id/forward` is the [platform forward](https://docs.minket.com/inbound/#forwarding) with Resend's body (`to`, `from`, `passthrough`, `text`, `html`).

## Contacts, segments and topics

| Calls | Paths |
| --- | --- |
| `contacts.create`, `list`, `get`, `update`, `remove` (by id or email, with or without `audienceId`) | `/contacts`, `/contacts/:id`, `/audiences/:id/contacts`, `/segments/:id/contacts` |
| `contacts.topics.list`, `update`; `contacts.segments.list`, `add`, `remove` | `/contacts/:id/topics`, `/contacts/:id/segments` |
| `contacts.imports.create`, `list`, `get` (the SDK uploads the file as form data) | `/contacts/imports` |
| `contactProperties.*` (`string` and `number`) | `/contact-properties` |
| `segments.*` and its alias `audiences.*` | `/segments`, `/audiences` |
| `topics.*` | `/topics` |

Segments made here are static lists. An unsubscribe on the [hosted pages](https://docs.minket.com/preferences/) shows up in `contacts.get` as `unsubscribed: true`.

## Broadcasts

`broadcasts.create`, `list`, `get`, `update`, `remove` and `send` work, and so do `cancel`, `recipients` and `clickedLinks` (6.22) and `duplicate` (6.34), in Resend's shapes.

- `audience_id` is accepted as the older name of `segment_id`. `create` with `send: true` sends at once and, if the send is refused, leaves no draft behind.
- Resend has three statuses: `draft`, `queued` (scheduled, sending or paused here) and `sent`.
- `duplicate` answers `{ "object": "broadcast", "id" }` with a draft.
- `recipients(id, { type, email, bounceType })` is the [recipient list](https://docs.minket.com/broadcasts/#progress-and-analytics) filtered by `event`: each item has `id` (a cursor, the contact id), `contact_id` and `email`, plus `count`, `clicked_links` or `bounce_type` as the type adds them. `clickedLinks(id)` is `GET /v1/broadcasts/{id}/links`. Opens and clicks are counted once per day (clicks per link), where Resend counts every repeat.
- Resend's variables work in a broadcast's content: `{{{FIRST_NAME}}}`, `{{{EMAIL}}}`, property keys in upper case and `{{{RESEND_UNSUBSCRIBE_URL}}}`.

## Automations and events

Resend's automations and events work from SDK 6.12.4:

```ts
await resend.events.create({ name: "user.signed_up", schema: { plan: "string" } });
const { data } = await resend.automations.create({
  name: "Welcome",
  status: "enabled",
  steps: [
    { key: "start", type: "trigger", config: { eventName: "user.signed_up" } },
    { key: "welcome", type: "send_email", config: { template: { id: "welcome", variables: { name: { var: "contact.first_name" } } } } },
    { key: "wait", type: "delay", config: { duration: "1 day" } },
    { key: "pro", type: "condition", config: { type: "rule", field: "plan", operator: "eq", value: "pro" } },
    { key: "trial", type: "contact_update", config: { properties: { plan: "trial" } } },
  ],
  connections: [
    { from: "start", to: "welcome" },
    { from: "welcome", to: "wait" },
    { from: "wait", to: "pro" },
    { from: "pro", to: "trial", type: "condition_not_met" },
  ],
});
await resend.events.send({ event: "user.signed_up", email: "jane@example.com", payload: { plan: "free" } });
```

| Resend | Minket |
| --- | --- |
| `trigger` step, `event_name` | the trigger: `contact.created`, `contact.updated`, an email event type, else a custom event; `topic.subscribed` with `topic_id` and `segment.entered` with `segment_id` are Minket's |
| `delay`, `duration` (`30 minutes`, `2 hours`, `3 days`, `1 week`, `PT2H`) | `wait` |
| `send_email`, `template`, `subject`, `from`, `reply_to` | `send_email` (variables `{ "var": "x" }` are `{{x}}`) |
| `wait_for_event`, `event_name`, `timeout` (90 days when omitted) | `wait_for_event`; connections `event_received` and `timeout` |
| `condition`, a rule or an `and`/`or` group of rules | `branch` with `condition`; connections `condition_met` and `condition_not_met`. Fields are contact fields (`email`, `first_name`, ...) or property keys; `exists` and `is_empty` are `is_set` and `is_not_set` |
| `contact_update`, `contact_delete`, `add_to_segment` | `update_contact`, `delete_contact`, `segment` with `add` |
| `status: "enabled"` / `"disabled"` | published and `active` / any other status |

- Steps Resend does not have read back in the same shape with Minket's names: `remove_from_segment` (`{ segment_id }`), `topic` (`{ action, topic_id }`), `exit` and the `condition` config `{ "type": "engagement", "event", "step" }`.
- An automation made here lets a contact run again once their run ended (`allow_reentry`), one run at a time; an update keeps the entry rule and re-entry setting it had.
- `remove` archives the automation; afterwards it is not found. `stop` answers `{ "object": "automation", "id", "status": "disabled" }`. `duplicate` (6.22.1) answers `{ "object": "automation", "id" }` with a draft that reads `disabled`.
- Runs read `running` (also while waiting), `completed` (also exited), `failed` or `cancelled` (stopped); their `steps` start with the trigger.
- `/events` keeps event definitions: `events.get` takes the id or the name, and a schema's field types (`string`, `number`, `boolean`, `date`) are checked when an event with that name is sent, on both `events.send` and `POST /v1/contacts/events`. A name without a definition is accepted as it is. Test keys read definitions but cannot create, change or delete them; they can still `events.send`.
- Not supported: `filter_rule` on `wait_for_event` and condition groups inside groups, both refused.

## Other differences

- **Test mode.** `minket_test_` keys accept and store mail, play back events and never deliver ([Test mode](https://docs.minket.com/sending/#test-mode)). Resend has no equivalent, so a project with its own sandbox transport can drop it.
- **Content checks.** On a marketing domain a send is refused with `422 validation_error` when the HTML is over 100 KB, the mail is images only, it links through a URL shortener, or it has no unsubscribe link; the message names the failed checks. Resend accepts these. A strict `batch.send` with one such item fails whole.
- **Rate limits.** 10 requests per second per key by default, reported in `ratelimit-limit`, `ratelimit-remaining` and `ratelimit-reset`.
