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.
# Keep the resend package. Keep every import. Keep every call.
RESEND_BASE_URL=https://api.emails.sh/resend
RESEND_API_KEY=esh_live_yourkeyNothing 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.
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.
// 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
/resend/emailsSend one. Answers {"id":"..."}, exactly as theirs does.
/resend/emails/batchUp to 100 in one call. Honours x-batch-validation.
/resend/emailsRecent sends, in their list envelope.
/resend/emails/:idOne email, with last_event derived from our delivery timeline.
/resend/emails/:id/cancelCall off a send booked with scheduled_at.
/resend/domainsSending domains, with name rather than domain.
/resend/domainsAdd one, and get back the DNS records to publish.
/resend/domains/:idOne domain and its records.
/resend/domains/:id/verifyCheck the DNS now rather than waiting for the sweep.
/resend/domains/:idRemove a domain.
/resend/api-keysList keys.
/resend/api-keysCreate one. permission maps onto our scopes.
/resend/api-keys/:idRevoke one.
/resend/emails/:idMove a scheduled send, keeping its id.
/resend/domains/:idSet the tracking subdomain.
/resend/contactsContacts, with first_name, last_name and properties.
/resend/contactsCreate one, optionally on segments and topics.
/resend/contacts/:id_or_emailOne contact, by uuid or by address, as theirs allows.
/resend/contacts/:id_or_emailUpdate names, properties, or unsubscribed.
/resend/contacts/:id_or_emailErase the person, not their delivery history.
/resend/contacts/:id_or_email/topicsTheir topic subscriptions.
/resend/contacts/:id_or_email/topicsOpt in or out, per topic.
/resend/contacts/:id_or_email/segmentsWhich lists they are on.
/resend/contacts/:id_or_email/segments/:segment_idAdd them to one.
/resend/contacts/:id_or_email/segments/:segment_idTake them off one.
/resend/segmentsLists, in their post-rename vocabulary.
/resend/segmentsCreate one.
/resend/segments/:idOne list.
/resend/segments/:idDelete one.
/resend/segments/:id/contactsWho is on it.
/resend/audiencesThe same lists, at their deprecated path.
/resend/audiencesCreate one, for SDKs older than their rename.
/resend/audiences/:idOne list.
/resend/audiences/:idDelete one.
/resend/audiences/:id/contactsWho is on it.
/resend/audiences/:id/contactsAdd somebody to it.
/resend/audiences/:id/contacts/:id_or_emailUpdate one membership. Unsubscribes from this list alone.
/resend/broadcastsCampaigns, in their list envelope.
/resend/broadcastsCreate one. send:true sends it in the same call.
/resend/broadcasts/:idOne campaign, with both audience_id and segment_id.
/resend/broadcasts/:idEdit a draft.
/resend/broadcasts/:idCancel one.
/resend/broadcasts/:id/sendSend it, now or at scheduled_at.
/resend/broadcasts/:id/metricsOpens, clicks, bounces, each with its rate.
/resend/topicsSubscription groups.
/resend/topicsCreate one, opt-in or opt-out by default.
/resend/topics/:idOne topic.
/resend/topics/:idRename or redescribe it.
/resend/topics/:idRetire it. The opt-outs on it survive.
/resend/templatesStored templates.
/resend/templatesCreate and publish one.
/resend/templates/:id_or_aliasOne template, by id or alias.
/resend/templates/:id_or_aliasEdit it. A body change writes and publishes a version.
/resend/templates/:id_or_aliasDelete it.
/resend/templates/:id_or_alias/publishPoint live sends at the latest version.
/resend/webhooksEndpoints and the events they take.
/resend/webhooksSubscribe one. The signing secret is shown once.
/resend/webhooks/:idOne endpoint.
/resend/webhooks/:idChange the URL, the events, or enable and disable it.
/resend/webhooks/:idRemove one.
/resend/suppressionsWho will not be mailed, and why.
/resend/suppressionsAdd an address by hand.
/resend/suppressions/:id_or_emailOne entry, by id or by address.
/resend/suppressions/:id_or_emailLift 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/batchfor 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_subdomainis 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/webhooksrefuses 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 isx-emailssh-signatureover 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.
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 terminalTwo 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
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.