# Resend API notes

What the Resend adapter does: endpoints, rate limits, the RFC 8058 headers it leaves to us, webhook normalization, and the quirks worth knowing.

What the Resend adapter does under the hood — the endpoints, the limits, the quirks worth knowing, and the capabilities it declares.

## Capabilities

| Capability | Value | Consequence |
| --- | --- | --- |
| `custom_headers` | true | Our headers are passed through. |
| `injects_unsubscribe_headers` | **false** | Nothing is auto-injected, so the RFC 8058 headers are entirely ours. |
| `send_time_suppression` | true | Silent, region-wide drops for bounces and complaints. |
| `suppression_read` | **`:none`** | No API at all. The nightly suppression sync doesn't run. |
| `suppression_webhooks` | true | The only route for Resend-side suppression. |
| `engagement_webhooks` | false | Per-domain tracking stays off — tracking is ours. |
| `push_unsubscribe` | false | No write API. The standing guard covers it. |
| `remote_delete` | false | GDPR erase can't remove the address Resend-side. |

## Sending

| Case | Endpoint |
| --- | --- |
| One message | `POST /emails` |
| More than one | `POST /emails/batch`, chunked at 100 |

Rate limit: **10 requests/second, team-wide**. The adapter declares `emails_per_minute: 600`, and the sender's pacer counts every send against that budget — one message per request through the queue, so 600 a minute is the honest ceiling. The account-wide maximum (`sending.max_emails_per_minute`, 300 by default) applies on top: the effective pace is whichever of the two is lower.

### Headers

```
"headers": {
  "List-Unsubscribe": "<https://your-mimeo.com/u/SIGNED_TOKEN>",
  "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
}
```

Resend injects nothing, so these are ours. The token is the same signed token the subscription page takes, which is what attributes a header unsubscribe to the exact email it came from. Both halves matter: without `List-Unsubscribe-Post`, a mail client shows a link rather than its own unsubscribe button.

The mail client's `POST /u/:token` goes directly to Mimeo and globally unsubscribes the person. It does not first pass through Resend or depend on a Resend webhook. Hosted subscription preferences do not change this behavior; selective tag or field changes use the separate [preference form](https://mimeohq.com/docs/api/subscriptions#preferences).

**Transactional mail carries no unsubscribe header.** You can't opt out of your own password reset.

## Webhooks

| Resend event | Normalizes to |
| --- | --- |
| `email.delivered` | `delivered` |
| `email.bounced` | `bounced` |
| `email.complained` | `complained` |
| `email.suppressed` | `suppressed` |
| anything else | acknowledged and ignored |

A bounce's `type` maps to our bounce kind: `Permanent` → hard (suppresses the person), `Transient` → soft (doesn't — a full mailbox is a try-again, not a dead address).

Signed with Svix: HMAC-SHA256 over `svix-id.svix-timestamp.body`, base64, compared in constant time, with a five-minute freshness window so a captured request can't be replayed tomorrow. Multiple `v1,…` signatures are accepted so a secret can be rotated without dropping anything mid-flight. See [Webhooks](https://mimeohq.com/docs/api/webhooks) for the shared contract.

## Quirks

- **The `User-Agent` header is mandatory.** Resend answers 403 without one, which is a confusing way to learn you forgot it.
- **Idempotency keys expire after 24 hours.** Fine — they exist to make one retry of one send safe, not to deduplicate forever.
- **The batch endpoint drops attachments.** Mimeo doesn't use attachments.
- **DKIM is 1024-bit only.**
- **Accepted ≠ delivered.** A suppressed address gets a success-shaped response and no email.

## Credentials

| Setting | What |
| --- | --- |
| `provider.resend.api_key` | The API key. |
| `provider.resend.webhook_secret` | The Svix signing secret, `whsec_…`. |

Both are entered on the Settings page and encrypted in your Mimeo's database, and neither ever appears in a [manager repo](https://mimeohq.com/docs/reference/definitions-repo).

`verify_config` calls `GET /domains` and **fails** when no domain is verified, naming the ones still pending — reporting a good connection that would refuse every send isn't a verification.

See also: [Resend setup](https://mimeohq.com/docs/providers/resend) · [Webhooks](https://mimeohq.com/docs/api/webhooks) · [Write your own adapter](https://mimeohq.com/docs/reference/write-your-own-adapter)
