# Migrate without changing your code

We serve Resend, Postmark, SendGrid, and Mailgun wire formats. Their own SDK talks to us. For Resend in most languages it is two environment variables.

Changing email provider is normally a rewrite: a different client, a different request body, different error handling, and a week of testing the paths that only run in production. It does not have to be. emails.sh runs four endpoints that speak the exact wire formats of Resend, Postmark, SendGrid, and Mailgun, so the SDK already in your package.json keeps working and points somewhere else.

## For a Resend codebase in most languages, it is two environment variables

The Resend Node client resolves its host from RESEND_BASE_URL before falling back to api.resend.com. So do the PHP, Ruby, Go, and Rust clients. Set RESEND_BASE_URL and RESEND_API_KEY, deploy, and no line of your code moves: the resend package stays, the imports stay, and resend.emails.send() returns the same object with the same shape. Removing the two variables rolls the whole thing back. Two exceptions worth knowing before you start: the Python client reads RESEND_API_URL rather than RESEND_BASE_URL, which is exactly the sort of detail that leaves a migration quietly still pointing at Resend, and the Java client hardcodes its host and cannot be redirected at all.

## For Postmark, SendGrid, and Mailgun it is one line, not zero

None of those three exposes an environment variable for the API host, so this is a code change, just a very small one. Postmark PHP sets a static, Postmark Ruby takes a host and a path prefix, Postmark .NET takes the base URI as a constructor argument. mailgun.js takes a url option on the client factory. SendGrid Node needs two lines in a particular order: setApiKey first, then client.setDefaultRequest("baseUrl", ...), because setApiKey overwrites the base URL and does it without an error. We would rather print that trap here than let you find it in production.

## The wire format is theirs, errors included

Postmark gets back To, SubmittedAt, MessageID, ErrorCode 0, Message OK, and errors as ErrorCode and Message. SendGrid gets a 202 with an empty body and the id in X-Message-Id, and errors as an errors array of message, field, and help. Mailgun gets a 200 with an angle-bracketed id and "Queued. Thank you." Resend gets its id object. Matching the success case is easy and matching the error case is the part that decides whether an SDK notices, so the error shapes are matched too.

## Anything unsafe to ignore is refused, not dropped

A compatibility layer that quietly discards a flag it does not understand will eventually mail somebody it should not have. SendGrid sandbox mode, bypass_list_management, and unsubscribe groups are refused. Postmark message streams are refused. Mailgun test mode, click-tracking overrides, and recipient variables are refused. Every refusal is a 4xx whose message names the feature and says what to use here instead, so an assistant reading the error can fix the call rather than retry it.

## What the two variables do not cover

Domain verification does not transfer between providers, so publish our records alongside the incumbent's and leave both in place while you cut over. A domain can carry several DKIM selectors at once, so nothing breaks and there is nothing to warm: reputation follows the domain, and the domain is not moving. Webhooks point at your handler and need our signature header verified instead of theirs. And import the suppression lists before you send anything, because every other step can be undone and mailing someone who already complained cannot.

## The API

| Endpoint | What it does |
| --- | --- |
| `https://api.emails.sh/resend` | Resend wire format: emails, batch, get, cancel, domains, API keys. |
| `https://api.emails.sh/postmark` | Postmark: email, batch, withTemplate, bounces, domains, message streams. |
| `https://api.emails.sh/sendgrid` | SendGrid v3: mail/send with personalizations, all five suppression lists, domains. |
| `https://api.emails.sh/mailgun` | Mailgun v3 and v4: messages, bounces, complaints, unsubscribes, domains. |

## The entire change, for a Resend codebase

Node, PHP, Ruby, Go, and Rust read this variable. Python reads RESEND_API_URL instead.

```bash
RESEND_BASE_URL=https://api.emails.sh/resend
RESEND_API_KEY=esh_live_yourkey
```

## What this does not do

### Only sending and the account resources around it

Audiences, broadcasts, contacts, and stored templates are not served on the compatibility endpoints, in any of the four formats. We have all four of those things, at our own /v1 routes with our own field names, and a call to a vendor's marketing endpoint answers with a message naming what to use instead rather than a generic failure.

### Two clients cannot be pointed here at all

The Resend Java client hardcodes its host. The Postmark Node and Java clients take a hostname with no path component, and our endpoints live under a path. In those three cases the move is to @emails.sh/sdk, which is the same one-call shape.

### The Resend list endpoint always says there is no next page

GET on the compatible emails list returns has_more false regardless, and reply_to comes back as an empty array. If you page through sent mail with the Resend client, read it from GET /v1/emails here instead.

### SendGrid batches are capped lower than theirs

A message with several personalizations becomes a batch here, capped at 100 against their 1000. A batch where every message fails is downgraded to a 400 so a client does not treat a total failure as accepted.

### It is a bridge, and bridges are for crossing

Running on a compatibility endpoint forever means a second field vocabulary between you and the product, and new features do not appear there. It exists so the migration is not the risky part; moving to @emails.sh/sdk afterwards is an afternoon and it is worth having.

## Questions

### Is it really two environment variables?

For a Resend codebase in Node, PHP, Ruby, Go, or Rust, yes. Their client reads RESEND_BASE_URL before falling back to their host. For Resend in Python it is still two variables but one is named RESEND_API_URL. For Postmark, SendGrid, and Mailgun it is one line of configuration instead, because none of those SDKs reads an environment variable for the host.

### What happens to my API key?

You use an emails.sh key, which starts with esh_. Every compatible endpoint accepts the auth scheme the original does: Bearer for Resend and SendGrid, X-Postmark-Server-Token or X-Postmark-Account-Token for Postmark, and HTTP Basic with the key as the password for Mailgun.

### How do I roll back?

Remove the variables, or revert the one line. Nothing about your code changed, so there is nothing to revert beyond configuration, and the incumbent account is still there with its domains verified because you left its DNS records in place.

### Will my deliverability change?

Your domain carries your reputation and your domain is not moving, so the change is smaller than it feels. The honest way to find out is to send a percentage of real traffic here for a week and compare your own bounce and complaint rates against the incumbent's on the same mail. We would rather you did that than take our word for it.

### Does the compatibility endpoint support scheduled sends and idempotency?

Yes. Resend scheduled_at maps to our send_at, and an idempotency-key header is forwarded on all four so a retried request returns the original id instead of mailing the same receipt twice.

## Related

- [Automations as code](https://emails.sh/features/automations-as-code.md)
- [SMTP relay](https://emails.sh/features/smtp-relay.md)
- [Deliverability](https://emails.sh/features/deliverability.md)

Docs: https://emails.sh/docs.md