---
title: How to migrate from SendGrid without changing your code
metaTitle: Migrate from SendGrid without changing your code
description: emails.sh serves SendGrid's v3 mail/send wire format, personalizations and all. Two lines of configuration in Node, one in every other language, and the order matters.
date: 2026-07-30
author: emails.sh
tags: Tutorials
---

The reason people stay on an email provider they have outgrown is rarely the provider. It is the twelve places in the codebase that call it, the personalizations array somebody built a helper around in 2022, and the certainty that the migration will break exactly one of the paths that only runs in production.

You can skip that part. emails.sh serves an endpoint that speaks SendGrid's v3 `mail/send` format exactly, so the `@sendgrid/mail` package in your `package.json` keeps working and points somewhere else.

## Do the suppression lists first

Before you change a line, before you touch DNS, before anything: import the suppression lists. All five of them.

SendGrid splits one idea across five places: `bounces`, `blocks`, `spam_reports`, `invalid_emails` and the global `unsubscribes` list. A migration that reads only `bounces` leaves the spam complaints behind, and those are the suppressions that matter most. Mailing an address that has already filed a complaint against you is the fastest available way to get a new sending setup filtered.

```bash
npx @emails.sh/cli migrate sendgrid
```

That reads all five, deduplicates them, prints a plan, and waits for you to say yes. Every other step in this post can be undone. This one cannot, which is why it goes first.

## Point the SDK, key first

Here is the whole code change in Node, and the order is load-bearing.

```js
const sgMail = require('@sendgrid/mail');

// setApiKey resets baseUrl to SendGrid's host, so the key goes FIRST.
sgMail.setApiKey(process.env.EMAILSSH_API_KEY);
sgMail.client.setDefaultRequest('baseUrl', 'https://api.emails.sh/sendgrid/');
```

`setApiKey` unconditionally overwrites `baseUrl` with SendGrid's regional host. Do it the other way round and your mail keeps going to SendGrid with no error, no warning and nothing in the logs to suggest anything is wrong. We would rather print this than let you find it.

None of SendGrid's seven SDKs reads an environment variable for the API host, so unlike a Resend migration this is a code change rather than a configuration change. In every language other than Node it is one line.

Your auth scheme does not change. SendGrid already uses a bearer token, so the key simply changes from one starting `SG.` to one starting `esh_`.

## What comes back

Identical shapes, because matching the success case is easy and matching the error case is what decides whether an SDK notices.

A successful send returns `202` with an empty body and the id in the `X-Message-Id` header. Errors return `{"errors":[{"message","field","help"}]}`, which is what their client deserialises.

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 total failure as acceptance.

## What is refused, and why that is the right behaviour

Sandbox mode, `bypass_list_management`, unsubscribe groups (`asm`), click tracking overrides and legacy substitutions are refused with a message naming what to use instead. They are not ignored.

This is deliberate and it is worth understanding before it surprises you. A compatibility layer that silently drops a flag it does not understand will eventually mail somebody it should not have: `bypass_list_management` and `asm` both exist precisely to control who gets mailed. Dropping either quietly is a correctness failure disguised as leniency. A `4xx` naming the feature is annoying for ten minutes; the alternative is a complaint you cannot explain.

## Republish domain authentication

SendGrid's domain authentication publishes CNAMEs pointing at its infrastructure. Ours are separate records, so add them alongside and leave SendGrid's in place until you have fully cut over. A domain can carry several DKIM selectors at once, so nothing breaks while both exist.

There is no warm-up to redo. Reputation follows the domain, and the domain is not moving.

## Move the event webhook, then Inbound Parse

Point your Event Webhook consumer at our `email.delivered`, `email.bounced` and `email.complained` events, and verify `x-emailssh-signature` instead of SendGrid's signature scheme. The payloads map cleanly onto whatever your existing handler does.

If you use Inbound Parse, change the MX record last. Mail can only route to one destination, so this is the one step with no overlap period.

## Cut over by percentage

Send 5% here for a week and compare your bounce and complaint rates against the SendGrid numbers on the same traffic. Then 50%. Then all of it. You are comparing your own numbers on your own mail, which is worth considerably more than anybody's marketing claim about deliverability, including ours.

## What does not come across

Marketing Campaigns. If a marketing team is using it, look at what they actually need before assuming this replaces it: there are audiences, segments, broadcasts and automations here, but there is no drag-and-drop design editor, no A/B testing and no signup form builder.

## Questions

### Can I roll back?

Revert the two lines. Nothing else about your code changed, and your SendGrid account still has its domains authenticated because you left the CNAMEs in place.

### Does the compatibility endpoint support scheduling and idempotency?

An `idempotency-key` header is forwarded, so a retried request returns the original id instead of mailing the same receipt twice. SendGrid's own `send_at` maps through as well.

### What about Postmark, Mailgun and Resend?

All three have compatibility endpoints too. Resend is the easiest by a distance: their Node, PHP, Ruby, Go and Rust clients read `RESEND_BASE_URL` from the environment, so it is genuinely two environment variables and no code change at all.

### Why is my free tier different?

SendGrid's free plan is a low daily allowance, historically around 100 emails a day. Ours is 3,000 a month with the same 100 a day ceiling, which is a monthly total you can actually spend.

## Related

- [Send a newsletter from your own app](/blog/send-a-newsletter-from-your-app)
- [SendGrid free tier changes](/blog/sendgrid-free-tier-changed)
- [Drop-in migration](/features/drop-in-migration)
- [emails.sh vs SendGrid](/vs/sendgrid)
