Migrating from SendGrid

Keep the SendGrid SDK and its personalizations array, and change the one line that says where it points. Bring all five suppression lists first.

emails.sh serves a SendGrid-compatible API at https://api.emails.sh/sendgrid. It takes their v3 mail/send body, personalizations array and all, and answers the way theirs does: 202 with an empty body and the id in the X-Message-Id header. Errors come back as {"errors":[{"message","field","help"}]}, which is the shape every one of their SDKs deserialises. Their auth is already ours, so the key just changes from SG. to esh_.

The entire change
// Node. The order matters and getting it wrong is silent.
// setApiKey resets baseUrl to api.sendgrid.com, so set the key FIRST.
sgMail.setApiKey('esh_live_yourkey');
sgMail.client.setDefaultRequest('baseUrl', 'https://api.emails.sh/sendgrid/');

# Python
SendGridAPIClient('esh_live_yourkey', host='https://api.emails.sh/sendgrid')

# Go
sendgrid.GetRequest(key, "/v3/mail/send", "https://api.emails.sh/sendgrid")

Every language, and how to point it here

These were read from each SDK's source, not from its documentation. All seven are overridable and none of them reads an environment variable: there is no SENDGRID_API_HOST and there never has been, so every one of these is a code change rather than a config change. It is still one line, except in Node where it is two.

LanguagePackageHow to point it at emails.shChange
Node / TypeScript@sendgrid/mailsetApiKey(key) then client.setDefaultRequest('baseUrl', '...')two lines, in that order
PythonsendgridSendGridAPIClient(key, host='https://api.emails.sh/sendgrid')constructor
PHPsendgrid/sendgridnew SendGrid($key, ['host' => 'https://api.emails.sh/sendgrid'])constructor option
Rubysendgrid-rubySendGrid::API.new(api_key: key, host: 'https://api.emails.sh/sendgrid')keyword arg
.NET / C#SendGridnew SendGridClient(key, host: "https://api.emails.sh/sendgrid")constructor
Javacom.sendgrid:sendgrid-javasg.setHost("api.emails.sh/sendgrid")one line, no scheme
Gogithub.com/sendgrid/sendgrid-goGetRequest(key, endpoint, "https://api.emails.sh/sendgrid")argument

Several of their clients also have a setDataResidency helper for choosing between api.sendgrid.com and api.eu.sendgrid.com. Do not use it to point here: it writes one of those two hosts and would silently undo the change you just made.

The body shape, which is the real difference

SendGrid is the hardest of the three because its send body is genuinely a different shape rather than the same shape with different spellings. A message there is a list of personalizations sharing one content array, and each personalization is its own envelope with its own recipients, its own subject, and its own template data. That is not one message with several recipients; it is several messages that happen to share a body.

What the compatibility endpoint takes, unchanged from theirs
{
  "personalizations": [
    { "to": [{ "email": "someone@example.com", "name": "Jane" }],
      "subject": "Your receipt" }
  ],
  "from": { "email": "receipts@yourdomain.com", "name": "Acme" },
  "content": [
    { "type": "text/plain", "value": "Thanks for your order." },
    { "type": "text/html", "value": "<p>Thanks for your order.</p>" }
  ],
  "categories": ["receipts"]
}

So one personalization becomes one send and several become a batch, and the caller sees the same 202 either way. Their cap is a thousand personalizations and our batch is a hundred, so a larger call is refused with the number of requests it has to become rather than being truncated. content is read by type rather than by position, so text/plain and text/html work in whichever order they arrive: their own spec states no ordering rule, their Python helper sorts and their Node helper does not.

What the compatibility endpoint covers

POST /sendgrid/v3/mail/send

Send. Answers 202 with an empty body and X-Message-Id, exactly as theirs does.

GET /sendgrid/v3/suppression/bounces

Your suppression list, as their bare array of {created, email, reason, status}.

GET /sendgrid/v3/suppression/blocks

The same rows their blocks list would carry.

GET /sendgrid/v3/suppression/spam_reports

Complaints, as their {created, email, ip}.

GET /sendgrid/v3/suppression/invalid_emails

Addresses that failed at the receiver.

GET /sendgrid/v3/suppression/unsubscribes

The global unsubscribe list.

GET /sendgrid/v3/whitelabel/domains

Sending domains, in their bare-array domain authentication shape.

POST /sendgrid/v3/whitelabel/domains

Add one, and get back the DNS records to publish.

GET /sendgrid/v3/templates

Stored templates, in their {result, _metadata} envelope.

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.

What differs

Bypassing suppression
mail_settings.bypass_list_management and its three narrower siblings are refused rather than ignored. The suppression list is not bypassable here, by design and without an exception. Every address on it bounced, complained, or opted out, and sending to it again is how a domain gets filtered. Lift a single address deliberately in the dashboard if it was suppressed in error.
Sandbox mode
mail_settings.sandbox_mode is refused. There is no validate-and-discard mode. Send to onboarding@emails.sh instead, which delivers only to the address that owns the workspace, so a test is a real message you can read rather than a response you have to trust.
Unsubscribe groups (asm)
Refused rather than ignored, because ignoring it would mail somebody who had opted out of that group. We have subscription topics with a hosted preference centre; recreate the groups as topics and pass topic on POST /v1/emails, which adds the opt-out link and the List-Unsubscribe header for you.
Click and subscription tracking
tracking_settings.click_tracking and subscription_tracking are refused. We do not rewrite links or append footers to your HTML, so a message that asked for either would go out without it and you would not know.
Legacy templates
personalizations[].substitutions is refused. Only dynamic template data is translated, onto our {{variable}} substitution. Handlebars conditionals and loops do not survive, so a d- template using {{#if}} or {{#each}} has to be flattened when you recreate it.
batch_id and ip_pool_name
batch_id has no equivalent: book each send with send_at and cancel it individually with DELETE /v1/messages/scheduled/:id. ip_pool_name is refused because pools are not something you name on a send here. Isolate reputation with a separate sending subdomain, or with a dedicated address if you have one: see /docs/dedicated-ips.
Subusers
A subuser is a tenant with its own reputation and its own key. Here that is a workspace: one per tenant or environment, each with its own keys, domains, and suppression list. They cannot be created over the API.
Marketing Campaigns
Not wire-compatible, and only partly replaced. We have audiences, segments, broadcasts, and automations, at /docs/broadcasts, /docs/segments, and /docs/automations, and they are reached through /v1 rather than through a SendGrid client. What we do not have is a drag-and-drop visual editor: a broadcast body is HTML or a stored template. If a marketing team is living in the Campaigns editor, look at what they build before you promise them a migration.
Regions
SendGrid runs api.sendgrid.com and api.eu.sendgrid.com, and the EU host only works with an EU-regional subuser key. We run one API host, and there is nothing to set on a send. Where the mail physically leaves from is a separate question, answered in /docs/regions.

Bringing the account across

The code is one line, but an account is also authenticated domains, event webhooks, dynamic templates, and five suppression lists. One command reads them 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
npx @emails.sh/cli migrate sendgrid
# reads SENDGRID_API_KEY, or pass --from-key
# reads all five suppression lists: bounces, blocks, spam_reports,
# invalid_emails, and unsubscribes, then domains, webhooks, and templates
# prints a plan and asks before writing anything

The suppressions are deduplicated across the five lists and written straight to your list here before any domain is added, so an interrupted run is one where the half that finished is the half that mattered. A copy is saved as sendgrid-suppressions.csv either way. An address on spam_reports is imported as a complaint whatever its free-text reason says, because the list it is on is better evidence than the string.

Cutting over

When you are ready to stop being compatible, move to /v1. One personalization becomes a flat to, cc, and bcc; the content array becomes html and text; dynamic_template_data becomes template.variables; send_at becomes an RFC 3339 string rather than Unix seconds. There is no deadline: the compatibility endpoint is a supported surface, not a temporary bridge.