How to migrate from SendGrid without changing your code

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.

4 min read

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.

Shell
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.

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.

Give your agent an address it can answer from.

Create an inbox