Migrating from SendGrid
Move your contacts, suppressions and sending code from SendGrid to Sendry.
SendGrid retired its permanent free plan for new signups in 2026, so a lot of teams are re-pricing their transactional email. This guide covers both halves of a move: the data (contacts and suppressions, handled by Sendry's importer) and the code (SDK and endpoint mapping).
Start with the data, because the importer needs nothing from you but a read-only API key.
1. Create a read-only SendGrid API key
In SendGrid: Settings → API Keys → Create API Key, choose Restricted Access, and grant read-only permission to:
| Permission | Why |
|---|---|
| Marketing | Lists, contacts, custom field definitions, and the contacts export job |
| Suppressions | Bounces, blocks, spam reports and global unsubscribes |
Nothing else is needed. Sendry never sends mail through your SendGrid account and never writes to it.
The key is validated the moment you paste it, with a single GET /v3/scopes call, so a typo or a revoked key fails immediately with a clear message instead of half an hour into an import. It is then sealed with AES-256-GCM (KMS-wrapped envelope encryption in production) before being stored, and is never written to logs.
2. Run the scan
In the Sendry dashboard, go to Migrate, pick SendGrid, and paste the key.
The scan is read-only and takes well under a minute on most accounts. It reports:
- Marketing lists, each with its contact count. Every list you select becomes a Sendry audience of the same name.
- All SendGrid contacts, a synthetic entry that imports every contact in the account into one audience. Useful if you keep no marketing lists, which is common for transactional-first accounts.
- Suppression counts for bounces, blocks, spam reports and unsubscribes.
- Custom field definitions, so you can see exactly what will land on each contact's metadata.
If your key is missing a permission, the scan says so on the review screen rather than silently returning an empty section.
3. Approve the import
Tick what you want and press Import selected. Two things are worth a moment's thought.
Blocks are unticked by default. A SendGrid block usually means the receiving server refused the message for reputation reasons, and that is frequently temporary. Importing blocks suppresses those addresses permanently in Sendry. Tick them only if you would rather be conservative than reach a possibly-recoverable address.
Contacts arrive through an export job. SendGrid's contacts export is asynchronous: Sendry starts the job, polls it with exponential backoff, downloads the file it produces and parses it. The contact counter can therefore sit still for a few minutes before it moves. That is the export building, not a stall.
The import is idempotent. A contact that already exists in Sendry is linked into the audience rather than duplicated; a suppression already on your list is left alone. If it fails part-way, Try again resumes: lists whose export already completed are skipped.
4. What comes across
Contacts
| SendGrid export column | Sendry contact field |
|---|---|
EMAIL | email, lowercased and trimmed |
FIRST_NAME | first_name |
LAST_NAME | last_name |
CONTACT_ID | metadata.sendgrid_contact_id |
PHONE_NUMBER, WHATSAPP, LINE, FACEBOOK, ALTERNATE_EMAILS, ADDRESS_LINE_1, ADDRESS_LINE_2, CITY, STATE_PROVINCE_REGION, POSTAL_CODE, COUNTRY, EXTERNAL_ID, UNIQUE_NAME | metadata.<same name> |
| Marketing custom fields | metadata.<custom field name>, typed by the field's Text / Number / Date declaration |
CREATED_AT, UPDATED_AT, ANONYMOUS_ID | Dropped. In Sendry created_at means "created here", so carrying the old value across would misreport your list's age |
Sendry contacts have email, first_name and last_name columns plus a string metadata map, so everything SendGrid keeps that Sendry has no column for is preserved as metadata rather than discarded.
Suppressions
| SendGrid list | Sendry reason |
|---|---|
| Bounces | hard_bounce |
| Blocks | hard_bounce |
| Spam reports | complaint |
| Global unsubscribes | unsubscribe |
Sendry keeps one suppression row per address, so an address on several SendGrid lists gets the strongest reason: complaint beats hard_bounce beats unsubscribe. Anything on the global unsubscribe list is also flagged unsubscribed on the contact record, so the opt-out is visible in both places.
What does not come across
- Sending domains. SendGrid's authenticated domains publish SendGrid's own DNS records, so they cannot be moved. Add your domains under Domains in Sendry and publish the records it gives you. This is the one step you must do by hand.
- API keys. Never migrated, for the obvious reason. Create fresh Sendry keys.
- Webhooks. Re-register your endpoint URLs in Sendry after the move.
- Templates. SendGrid dynamic templates are Handlebars (
{{#if}},{{#each}}, dotted paths,{{{raw}}}) and legacy templates use<%body%>. Sendry's HTML engine substitutes plain{{variable}}and nothing else, so an automatic translation would quietly break every template carrying logic. Recreate them under Templates, or send fully-rendered HTML from your own React Email components.
5. Plan limits
Contacts count against your plan's contact limit exactly as they do when you create them by hand. If an import hits the limit it stops the contact phase, keeps everything already imported, and marks the result partial with the limit named. Upgrade and press Try again to bring the rest across; nothing is duplicated.
Code migration
Package swap
npm remove @sendgrid/mail
npm install sendry-sdk
Initialization
// Before (SendGrid)
import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
// After (Sendry)
import { Sendry } from "sendry-sdk";
const sendry = new Sendry(process.env.SENDRY_API_KEY);
Sending
// Before (SendGrid)
await sgMail.send({
to: "user@example.com",
from: "hello@acme.com",
subject: "Hello",
html: "<p>Hello!</p>",
});
// After (Sendry)
const email = await sendry.emails.send({
from: "hello@acme.com",
to: "user@example.com",
subject: "Hello",
html: "<p>Hello!</p>",
});
Differences worth knowing:
- Sendry takes
fromandtoas plain strings or arrays of strings, not{ email, name }objects. Use"Acme <hello@acme.com>"when you want a display name. - Sendry throws on failure instead of returning a response you have to inspect. Catch
ApiError,RateLimitErrorandAuthenticationErrorfromsendry-sdk. - Sendry returns the email object directly, with its id, so you can correlate webhooks without reading a header.
Error handling
// Before (SendGrid)
try {
await sgMail.send(msg);
} catch (error) {
console.error(error.response?.body?.errors);
}
// After (Sendry)
import { ApiError, RateLimitError, AuthenticationError } from "sendry-sdk";
try {
const email = await sendry.emails.send(params);
} catch (error) {
if (error instanceof AuthenticationError) {
console.error("Bad API key");
} else if (error instanceof RateLimitError) {
await sleep(error.retryAfter * 1000);
} else if (error instanceof ApiError) {
console.error(error.statusCode, error.code, error.message);
}
}
REST API endpoint mapping
| Feature | SendGrid | Sendry |
|---|---|---|
| Send email | POST /v3/mail/send | POST /v1/emails |
| Send batch | POST /v3/mail/send with multiple personalizations | POST /v1/emails/batch |
| Get email | GET /v3/messages/:id | GET /v1/emails/:id |
| List emails | GET /v3/messages | GET /v1/emails |
| Authenticate domain | POST /v3/whitelabel/domains | POST /v1/domains |
| Validate domain | POST /v3/whitelabel/domains/:id/validate | POST /v1/domains/:id/verify |
| List domains | GET /v3/whitelabel/domains | GET /v1/domains |
| Create API key | POST /v3/api_keys | POST /v1/api-keys |
| List API keys | GET /v3/api_keys | GET /v1/api-keys |
| Contacts | PUT /v3/marketing/contacts | POST /v1/contacts |
| Lists / audiences | GET /v3/marketing/lists | GET /v1/audiences |
| Bounces | GET /v3/suppression/bounces | GET /v1/suppression |
| Spam reports | GET /v3/suppression/spam_reports | GET /v1/suppression |
| Unsubscribes | GET /v3/suppression/unsubscribes | GET /v1/suppression |
| Event webhook | POST /v3/user/webhooks/event/settings | POST /v1/webhooks |
Sendry folds SendGrid's four suppression endpoints into one GET /v1/suppression list with a reason field, which is the same shape the importer writes.
Environment variable rename
# .env
# Before
SENDGRID_API_KEY=SG.abc123
# After
SENDRY_API_KEY=sn_live_abc123
Webhook event names
| Event | SendGrid | Sendry |
|---|---|---|
| Delivered | delivered | email.delivered |
| Bounced | bounce | email.bounced |
| Blocked / dropped | blocked, dropped | email.bounced |
| Opened | open | email.opened |
| Clicked | click | email.clicked |
| Spam report | spamreport | email.complained |
| Unsubscribe | unsubscribe, group_unsubscribe | No direct event; the contact is flagged unsubscribed and added to your suppression list, and contact.updated fires |
SendGrid posts an array of events per request; Sendry posts one signed event per request. Verify the signature as described in Webhook verification.
Checklist
- Create a restricted SendGrid key with read access to Marketing and Suppressions
- Run the scan under Migrate → SendGrid and review what was found
- Import contacts and suppressions
- Add your sending domains under Domains and publish Sendry's DNS records
- Recreate templates that used Handlebars logic
- Replace
@sendgrid/mailwithsendry-sdk - Change
SENDGRID_API_KEYtoSENDRY_API_KEY - Re-register webhook URLs and map the event names above
- Send a test email, confirm delivery, then cut traffic over