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_.
// 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.
| Language | Package | How to point it at emails.sh | Change |
|---|---|---|---|
Node / TypeScript | @sendgrid/mail | setApiKey(key) then client.setDefaultRequest('baseUrl', '...') | two lines, in that order |
Python | sendgrid | SendGridAPIClient(key, host='https://api.emails.sh/sendgrid') | constructor |
PHP | sendgrid/sendgrid | new SendGrid($key, ['host' => 'https://api.emails.sh/sendgrid']) | constructor option |
Ruby | sendgrid-ruby | SendGrid::API.new(api_key: key, host: 'https://api.emails.sh/sendgrid') | keyword arg |
.NET / C# | SendGrid | new SendGridClient(key, host: "https://api.emails.sh/sendgrid") | constructor |
Java | com.sendgrid:sendgrid-java | sg.setHost("api.emails.sh/sendgrid") | one line, no scheme |
Go | github.com/sendgrid/sendgrid-go | GetRequest(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.
{
"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
/sendgrid/v3/mail/sendSend. Answers 202 with an empty body and X-Message-Id, exactly as theirs does.
/sendgrid/v3/suppression/bouncesYour suppression list, as their bare array of {created, email, reason, status}.
/sendgrid/v3/suppression/blocksThe same rows their blocks list would carry.
/sendgrid/v3/suppression/spam_reportsComplaints, as their {created, email, ip}.
/sendgrid/v3/suppression/invalid_emailsAddresses that failed at the receiver.
/sendgrid/v3/suppression/unsubscribesThe global unsubscribe list.
/sendgrid/v3/whitelabel/domainsSending domains, in their bare-array domain authentication shape.
/sendgrid/v3/whitelabel/domainsAdd one, and get back the DNS records to publish.
/sendgrid/v3/templatesStored 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_managementand 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_modeis 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_trackingandsubscription_trackingare 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_idhas no equivalent: book each send withsend_atand cancel it individually with DELETE/v1/messages/scheduled/:id.ip_pool_nameis 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/v1rather 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.
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 anythingThe 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.