# Migrating from Resend

Keep the resend package, keep every import, keep every call. Change two environment variables and deploy.

emails.sh serves a Resend-compatible API at https://api.emails.sh/resend. It speaks their exact request and response format, including their error shape, so their official SDK talks to it without noticing. Their Node client resolves its host as options.baseUrl, then process.env.RESEND_BASE_URL, then api.resend.com, which means the whole migration for a JavaScript codebase is two environment variables and a deploy.

The entire change:
```bash
# Keep the resend package. Keep every import. Keep every call.
RESEND_BASE_URL=https://api.emails.sh/resend
RESEND_API_KEY=esh_live_yourkey
```

Nothing else moves. The resend package stays in your package.json, your imports stay, resend.emails.send() stays, and the object it returns is the same object with the same id field. Roll it back by removing the two variables.

Create the key first with `npx @emails.sh/cli keys create production`, or at https://emails.sh/dashboard/keys. Keys here start with esh_. A re_ key is accepted by the compatibility endpoint too, so a half-finished migration fails with "unknown key" rather than "malformed key", but a Resend key will never authenticate here.

### Every language, and how to point it here

These were read from each SDK's source, not from its documentation. Five of the nine take an environment variable, three need one line of configuration, and one cannot be redirected at all. Note that the Python client reads RESEND_API_URL, not RESEND_BASE_URL, which is the single most common way a migration silently keeps sending through Resend.

| Language | Package | How to point it at emails.sh | Change |
| --- | --- | --- | --- |
| Node / TypeScript | resend | RESEND_BASE_URL=https://api.emails.sh/resend | env var |
| Python | resend | RESEND_API_URL=https://api.emails.sh/resend | env var |
| PHP | resend/resend-php | RESEND_BASE_URL=api.emails.sh/resend | env var |
| Ruby | resend | RESEND_BASE_URL=https://api.emails.sh/resend/ | env var, set before require |
| Go | github.com/resend/resend-go/v3 | RESEND_BASE_URL=https://api.emails.sh/resend/ | env var |
| Rust | resend-rs | RESEND_BASE_URL=https://api.emails.sh/resend | env var |
| .NET / C# | Resend | o.ApiUrl = "https://api.emails.sh/resend" | one line |
| Elixir | resend | config :resend, Resend.Client, base_url: "..." | one line |
| Java | com.resend:resend-java | Not overridable. Use @emails.sh/sdk. | switch SDK |

Ruby, Go, and PHP bake a trailing slash into their default, so give them one too. PHP's BaseUri adds https:// itself when the value has no scheme. Ruby reads the variable at require time, so set it before your app boots rather than in an initializer.

The three that need a line of code:
```ts
// Node, where you would rather not use the environment
new Resend(process.env.RESEND_API_KEY, { baseUrl: 'https://api.emails.sh/resend' });

// .NET, in your service registration
services.AddResend(o => {
  o.ApiToken = Configuration["Resend:ApiToken"];
  o.ApiUrl   = "https://api.emails.sh/resend";
});

// Elixir, in config/runtime.exs
config :resend, Resend.Client,
  api_key: System.get_env("RESEND_API_KEY"),
  base_url: "https://api.emails.sh/resend"
```

Java is the exception. com.resend:resend-java holds its host in `public static final String BASE_API = "https://api.resend.com"` and offers no public way past it, so there is nothing to override. Use @emails.sh/sdk for Java instead: the call shape is the same and it is a smaller change than the fork would be.

### What the compatibility endpoint covers

- `POST /resend/emails` Send one. Answers {"id":"..."}, exactly as theirs does.
- `POST /resend/emails/batch` Up to 100 in one call. Honours x-batch-validation.
- `GET /resend/emails` Recent sends, in their list envelope.
- `GET /resend/emails/:id` One email, with last_event derived from our delivery timeline.
- `POST /resend/emails/:id/cancel` Call off a send booked with scheduled_at.
- `GET /resend/domains` Sending domains, with name rather than domain.
- `POST /resend/domains` Add one, and get back the DNS records to publish.
- `GET /resend/domains/:id` One domain and its records.
- `POST /resend/domains/:id/verify` Check the DNS now rather than waiting for the sweep.
- `DELETE /resend/domains/:id` Remove a domain.
- `GET /resend/api-keys` List keys.
- `POST /resend/api-keys` Create one. permission maps onto our scopes.
- `DELETE /resend/api-keys/:id` Revoke one.
- `PATCH /resend/emails/:id` Move a scheduled send, keeping its id.
- `PATCH /resend/domains/:id` Set the tracking subdomain.
- `GET /resend/contacts` Contacts, with first_name, last_name and properties.
- `POST /resend/contacts` Create one, optionally on segments and topics.
- `GET /resend/contacts/:id_or_email` One contact, by uuid or by address, as theirs allows.
- `PATCH /resend/contacts/:id_or_email` Update names, properties, or unsubscribed.
- `DELETE /resend/contacts/:id_or_email` Erase the person, not their delivery history.
- `GET /resend/contacts/:id_or_email/topics` Their topic subscriptions.
- `PATCH /resend/contacts/:id_or_email/topics` Opt in or out, per topic.
- `GET /resend/contacts/:id_or_email/segments` Which lists they are on.
- `POST /resend/contacts/:id_or_email/segments/:segment_id` Add them to one.
- `DELETE /resend/contacts/:id_or_email/segments/:segment_id` Take them off one.
- `GET /resend/segments` Lists, in their post-rename vocabulary.
- `POST /resend/segments` Create one.
- `GET /resend/segments/:id` One list.
- `DELETE /resend/segments/:id` Delete one.
- `GET /resend/segments/:id/contacts` Who is on it.
- `GET /resend/audiences` The same lists, at their deprecated path.
- `POST /resend/audiences` Create one, for SDKs older than their rename.
- `GET /resend/audiences/:id` One list.
- `DELETE /resend/audiences/:id` Delete one.
- `GET /resend/audiences/:id/contacts` Who is on it.
- `POST /resend/audiences/:id/contacts` Add somebody to it.
- `PATCH /resend/audiences/:id/contacts/:id_or_email` Update one membership. Unsubscribes from this list alone.
- `GET /resend/broadcasts` Campaigns, in their list envelope.
- `POST /resend/broadcasts` Create one. send:true sends it in the same call.
- `GET /resend/broadcasts/:id` One campaign, with both audience_id and segment_id.
- `PATCH /resend/broadcasts/:id` Edit a draft.
- `DELETE /resend/broadcasts/:id` Cancel one.
- `POST /resend/broadcasts/:id/send` Send it, now or at scheduled_at.
- `GET /resend/broadcasts/:id/metrics` Opens, clicks, bounces, each with its rate.
- `GET /resend/topics` Subscription groups.
- `POST /resend/topics` Create one, opt-in or opt-out by default.
- `GET /resend/topics/:id` One topic.
- `PATCH /resend/topics/:id` Rename or redescribe it.
- `DELETE /resend/topics/:id` Retire it. The opt-outs on it survive.
- `GET /resend/templates` Stored templates.
- `POST /resend/templates` Create and publish one.
- `GET /resend/templates/:id_or_alias` One template, by id or alias.
- `PATCH /resend/templates/:id_or_alias` Edit it. A body change writes and publishes a version.
- `DELETE /resend/templates/:id_or_alias` Delete it.
- `POST /resend/templates/:id_or_alias/publish` Point live sends at the latest version.
- `GET /resend/webhooks` Endpoints and the events they take.
- `POST /resend/webhooks` Subscribe one. The signing secret is shown once.
- `GET /resend/webhooks/:id` One endpoint.
- `PATCH /resend/webhooks/:id` Change the URL, the events, or enable and disable it.
- `DELETE /resend/webhooks/:id` Remove one.
- `GET /resend/suppressions` Who will not be mailed, and why.
- `POST /resend/suppressions` Add an address by hand.
- `GET /resend/suppressions/:id_or_email` One entry, by id or by address.
- `DELETE /resend/suppressions/:id_or_email` Lift one, where lifting it is allowed.

These are a translation over the same /v1 handlers everything else uses, not a second implementation. A send through the compatibility endpoint passes the same suppression check, the same quota, the same spend cap, and the same idempotency table as a send through /v1/emails, because none of that logic lives in the compatibility layer and none of it can be skipped by using it.

### What differs

- **Contacts, segments, audiences, and broadcasts**: All wire-compatible now. Their contact is our contact plus its address plus its attributes; their segment and their older audience are both our audience, because at Resend the two are one thing under two names. Their properties are our contact attributes. A contact created here with no first or last name is displayed under its address, which is what their dashboard shows too.
- **unsubscribed on a contact**: Theirs is one boolean meaning "leave this person out of broadcasts". We record it in two places, because neither alone can answer it: every audience membership, which is what a broadcast reads, and the suppression list, which is what stops mail to somebody who is on no list. The difference to know about is that our suppression also holds back transactional mail to that address. That is the safe direction to be wrong in. Set it per list instead with PATCH /resend/audiences/:id/contacts/:id_or_email, which touches that membership only.
- **topic_id on a send**: Honoured. It files the message under a subscription topic, so somebody who opted out of that topic does not receive it and the List-Unsubscribe header points at the right place. A topic id that does not exist here is a refusal rather than a message that went out unfiled. See /docs/topics.
- **Rescheduling**: PATCH /emails/:id moves a booked send and keeps its id, as theirs does. It is conditional on the send not having been picked up yet: once it is on its way you get a 409 saying so, rather than a 200 about a message that is already in flight.
- **Recipient limits**: Resend documents 50 against to alone. Ours is 50 across to, cc, and bcc together, because a bcc recipient costs exactly as much to deliver to as a to recipient and one call that fans out to hundreds of strangers is the first thing a stolen key does. A call inside their limit can therefore be outside ours, and the refusal says so with the arithmetic in it rather than leaving you to count. Use POST /emails/batch for more.
- **Inline images (content_id)**: Refused, not ignored. We do not build multipart/related parts, so a cid: reference would render as a broken image in the recipient's inbox with nothing in any log connecting it to the field that caused it. Reference the image by https URL in the HTML instead: it renders everywhere and is not hidden by the same image rules that block inline parts anyway.
- **Attachment fetching**: attachments[].path works and the ceiling is 40 MB, the same number Resend documents, counted across the whole message after base64 encoding. The URL must be https and resolve to a publicly routable address, and redirects are not followed. Send the bytes as content for anything behind authentication.
- **Domain settings**: region, tls, custom_return_path, open_tracking, click_tracking, and capabilities are each either applied or refused by name on POST /domains, never accepted and dropped. We send from eu-west-1 only, TLS is opportunistic, the Return-Path is managed for you, and there is no domain-level tracking switch. tracking_subdomain is real and PATCH /domains/:id sets it, once the CNAME resolves. See /docs/tracking-domain.
- **Deleting a domain**: Refused while mailboxes still receive mail on it, because deleting it would take those addresses and the conversations in them with it. Resend has no equivalent of this, since Resend domains do not receive. Remove the mailboxes first.
- **domain_id on an API key**: Refused. A key here carries scopes (what it may do) rather than a domain (where it may send from), and there is no way to issue a domain-scoped one, so accepting the field would hand you a wider key than you asked for. Every send is still checked against the domains this workspace has verified, so a key cannot send from a domain you do not own. Use one workspace per domain if the separation is load-bearing.
- **Webhooks**: Four names mean the same thing on both sides: email.sent, email.delivered, email.bounced, email.complained. Those branches of your switch on event.type need no edit. Two do not exist here: there is no email.opened and no email.clicked, and POST /v1/webhooks refuses an events array containing either, by name, rather than accepting it and never firing. Delete those two branches and read the numbers instead: a click is a counter on the message and a row in GET /v1/analytics, and opens sit beside them as a floor rather than a count, because a privacy proxy fetches the pixel on the recipient's behalf whether or not anybody looked. We also send events Resend does not: email.received and email.filtered for inbound mail, domain.verified, the three workspace ones, and two for automation runs. The whole list is on the webhooks page. The signature changes too: theirs is svix, ours is x-emailssh-signature over the raw body. That is the one part of this surface we cannot absorb for you, because the signature is computed over the body your receiver reads. The secret is shown once, when you create the endpoint. See https://emails.sh/docs/webhooks.
- **Topic visibility**: Refused. Their visibility field hides a topic from contacts who have not opted in; our hosted preference centre lists every live topic to everybody, so accepting "private" would show somebody a topic they were promised would be hidden. Leave the field out, or pass "public".
- **Contact properties as a schema**: Their contact-properties API declares fields ahead of time. We have no such registry: an attribute is created by being written. The cost is that a typo in a property name is a new property rather than an error. Everything else about them works, through properties on a contact.
- **Test addresses**: Theirs live on resend.dev. Ours live on emails.sh: delivered@, bounced@, complained@, and suppressed@, with the same sub-addressing and the same behaviour. Rewrite the domain and your existing tests keep asserting what they asserted.
- **Message ids**: Ours are UUIDs. Mail you sent through Resend before switching stays in their logs, and its id will not resolve here.

### What we do that they do not

Receiving. emails.sh runs inbound MX on your domain, parses the MIME, extracts attachments, and threads replies against In-Reply-To and References, so a customer answering your notification arrives as part of a conversation you can query and reply to through the same API. Resend is a sending product, and this is the clearest structural difference between them.

The MCP server. The assistant writing your integration can also operate your account while it works: add the domain, read back the exact DNS rows, trigger the verification check, mint a scoped key, replay a webhook delivery, and look up why a specific message bounced. That removes the step where setup stalls, which is "now open the dashboard and paste these records by hand". See https://emails.sh/docs/mcp.

The agent-first integration surface. Every page on this site is available as markdown at the same URL plus .md, the whole API is one fetch at https://emails.sh/llms.txt, and there is an installable skill for Cursor and Claude Code that means the assistant knows the API without fetching anything. Errors are written to be acted on: a refusal carries a code to branch on, a message naming the value that was wrong, and a sentence saying what to do with the URL in it.

### Bringing the account across

The code is two variables, but an account is also domains, webhook endpoints, and a suppression list that took years of bounces to build. One command reads them from Resend and imports what it can. It prints a plan and waits for a yes before writing anything, and it is safe to run again.

Import the account:
```bash
npx @emails.sh/cli migrate resend
# reads RESEND_API_KEY, or pass --from-key with your Resend key
# prints a plan and asks before writing anything

npx @emails.sh/cli migrate compat
# the table above, in your terminal
```

Two things cannot be copied and the command says so rather than leaving you to find out. Domain verification is a claim on DNS rather than a row, so each domain added here returns its own records to publish; a domain can carry several DKIM selectors at once, so both providers can sign for it while you compare. And API keys are shown once at Resend as they are here, so they cannot be read back and have to be recreated.

Import the suppression list before you send anything real. Those addresses bounced or complained already, and mailing them again from a new provider is the most reliable way to get a domain filtered in its first week.

### Cutting over

- **Send one message and read it back** (/docs/quickstart): Set the two variables in a scratch environment, send to yourself, and fetch it with GET /emails/:id. If the id comes back and last_event moves to delivered, the integration is done.
- **Publish our DNS records alongside theirs** (/docs/domains): Add the domain here and publish our DKIM, SPF, and return-path records. Nothing breaks: a domain may carry several DKIM selectors, so both providers keep signing while you decide.
- **Move a percentage** (/docs/delivery): Send 5% here for a week and compare bounce and complaint rates on the same traffic. There is nothing to warm up: reputation follows the domain, and the domain is not moving.
- **Change the webhook signature check** (/docs/webhooks): Point an endpoint here, verify against x-emailssh-signature, and map email.delivered, email.bounced, and email.complained onto whatever your Resend handler already does.

When you are ready to stop being compatible, move to /v1. It is the same request body with scheduled_at spelled send_at and tags as an object rather than a list of pairs, and it returns the delivery timeline rather than one word for it. There is no deadline: the compatibility endpoint is a supported surface, not a temporary bridge.

---

Base URL: https://emails.sh/v1. Auth: `Authorization: Bearer esh_...`.
Whole API in one file: https://emails.sh/llms.txt. All documentation: https://emails.sh/docs.md.
