Migrate transactional email from SendGrid to Amazon SES
Verify your domain, move suppression lists, adapt templates and events, and shift sending to SES gradually. Keep SendGrid ready for rollback.
From SendGrid to Amazon SES · Published 2026-10-08
Before you commit
- Hands-on effort
- 2–4 days of hands-on work
- Elapsed time
- Allow production-access approval, a 1–2 week staged cutover and one billing cycle of rollback capacity.
- Examples cover
- AWS CLI v2 · AWS SDK for JavaScript v3
- Content review
- 2026-10-09
- Integration testing
- Not recorded. Rehearse the commands and rollback on staging.
Prerequisites
- Domain DNS access, SendGrid exports, SES production access and sufficient regional sending quotas.
- Owners for suppression handling, templates, event consumers and deliverability monitoring.
Don’t migrate yet if…
- You cannot preserve unsubscribes and bounce/complaint handling across both sending paths.
- Required SendGrid features or deliverability support have no replacement your team can operate.
SendGrid sells monthly plans. At 100,000 emails a month that’s $34.95 on Essentials, or $89.95 on Pro if you need a dedicated IP or EU data residency. Amazon SES charges $0.10 per 1,000 emails with no plan, so the same volume costs $10. At 700,000 a month it’s $499 against $70, plus $24.95 if you lease a dedicated IP. Run your own numbers in the calculator.
Changing the send call is the small part. Before sending real traffic, carry over bounces and unsubscribes, test every template, and make sure delivery events reach your handler.
0. Before you start
- Pick the region first. Identities, quotas, sandbox status and suppression lists are all per region in SES. For GDPR, use an EU region such as
eu-west-1(Ireland),eu-central-1(Frankfurt) oreu-north-1(Stockholm), and keep SNS topics and S3 buckets in the same region. - Request production access now. New accounts start in the sandbox: verified recipients only, 200 emails per 24 hours, 1 per second. You also can’t import suppressions until you’re out. AWS replies within 24 hours, longer if it asks questions.
aws sesv2 put-account-details --region eu-west-1 \
--production-access-enabled --mail-type TRANSACTIONAL \
--website-url https://example.com \
--additional-contact-email-addresses ops@example.com --contact-language EN
- After approval, check your quota with
aws sesv2 get-account(Max24HourSend,MaxSendRate). If your peak day is close to it, ask for an increase before you cut over. - List what you use in SendGrid: dynamic templates, Event Webhook consumers, categories and
custom_args, link branding, subusers, IP pools, and anyX-SMTPAPIheaders.
1. Verify the domain
aws sesv2 create-email-identity --email-identity example.com
The output has three DkimAttributes.Tokens. Publish each as a CNAME from <token>._domainkey.example.com to <token>. plus the DkimAttributes.SigningHostedZone value from the same output (typically dkim.amazonses.com, but it varies by region). These names don’t clash with SendGrid’s s1/s2._domainkey records, so both providers can sign mail during the migration. SES checks DNS for up to 72 hours.
Then set a custom MAIL FROM domain. Without one, the envelope sender is a subdomain of amazonses.com, SPF can’t align with your From domain, and DMARC depends on DKIM alone.
aws sesv2 put-email-identity-mail-from-attributes --email-identity example.com \
--mail-from-domain bounce.example.com --behavior-on-mx-failure USE_DEFAULT_VALUE
| Name | Type | Value |
|---|---|---|
bounce.example.com |
MX | 10 feedback-smtp.eu-west-1.amazonses.com |
bounce.example.com |
TXT | "v=spf1 include:amazonses.com ~all" |
Use a subdomain you don’t send from or receive mail on. SendGrid’s em1234 return-path subdomain stays as it is.
2. Configuration set and events
SES events go to a configuration set, not to a webhook URL. Create one and send bounces, complaints and the rest to SNS (HTTPS subscription to your existing endpoint) or to EventBridge’s default bus.
aws sesv2 create-configuration-set --configuration-set-name transactional \
--tracking-options CustomRedirectDomain=click.example.com,HttpsPolicy=REQUIRE
aws sesv2 create-configuration-set-event-destination \
--configuration-set-name transactional --event-destination-name to-sns \
--event-destination '{"Enabled":true,"MatchingEventTypes":["SEND","DELIVERY","BOUNCE","COMPLAINT","REJECT","DELIVERY_DELAY","RENDERING_FAILURE"],"SnsDestination":{"TopicArn":"arn:aws:sns:eu-west-1:123456789012:ses-events"}}'
| SendGrid event | SES event |
|---|---|
processed |
Send |
delivered |
Delivery |
deferred |
DeliveryDelay |
bounce |
Bounce, bounceType Permanent or Transient |
dropped (suppressed) |
Bounce with bounceSubType OnAccountSuppressionList |
spamreport |
Complaint |
open / click |
Open / Click |
category, custom_args |
mail.tags, from EmailTags you set when sending |
Your handler changes in two ways. SendGrid POSTs batched JSON arrays, while SNS sends one event per request, wrapped in an SNS envelope with the SES event as a JSON string in Message. Your endpoint must also confirm the subscription and should verify the SNS signature.
Open and click tracking only happens when OPEN and CLICK are in an event destination. The custom redirect domain (click.example.com above) replaces SendGrid link branding. Over HTTPS it needs a CDN such as CloudFront in front of the regional awstrack.me endpoint, with your certificate (details). Leave tracking off for password resets and login links.
3. Move the suppression lists
Export every SendGrid list. Each endpoint returns at most 500 entries per page:
for list in bounces invalid_emails spam_reports blocks unsubscribes; do
offset=0; : > "$list.ndjson"
while :; do
page=$(curl -s -H "Authorization: Bearer $SENDGRID_API_KEY" \
"https://api.sendgrid.com/v3/suppression/$list?limit=500&offset=$offset")
echo "$page" | jq -c '.[]' >> "$list.ndjson"
[ "$(echo "$page" | jq length)" -lt 500 ] && break
offset=$((offset + 500))
done
done
The SES account-level suppression list only accepts two reasons, BOUNCE and COMPLAINT:
bouncesandinvalid_emails→BOUNCEspam_reports→COMPLAINTblocks: don’t import. They’re usually about SendGrid’s IPs or your content, not the address.unsubscribes(global) and group unsubscribes (/v3/asm/...): store these in your own database and check them before sending. The SES list blocks all mail to an address, including password resets.
{ jq -r '.email + ",BOUNCE"' bounces.ndjson invalid_emails.ndjson
jq -r '.email + ",COMPLAINT"' spam_reports.ndjson; } | sort -t, -k1,1 -u > ses-suppressions.csv
aws s3 cp ses-suppressions.csv s3://my-bucket-eu-west-1/ses-suppressions.csv
aws sesv2 create-import-job \
--import-destination 'SuppressionListDestination={SuppressionListImportAction=PUT}' \
--import-data-source S3Url=s3://my-bucket-eu-west-1/ses-suppressions.csv,DataFormat=CSV
The bucket must be in the same region, and SES needs read access to it. Each import file can hold up to 100,000 addresses; use split -l 100000 for more. For a few addresses, aws sesv2 put-suppressed-destination --email-address a@example.org --reason BOUNCE works too. All non-send SES API calls are limited to one per second, though, so use the import job for anything big. Check results with aws sesv2 get-import-job --job-id ….
4. Port the templates
Export them with GET /v3/templates?generations=dynamic&page_size=200. Each version has subject, html_content and plain_content. Both systems use Handlebars, but they behave differently:
| SendGrid dynamic templates | SES templates | |
|---|---|---|
{{var}} with HTML in the value |
Escaped; {{{var}}} outputs raw HTML |
Not escaped. Escape user input yourself |
| Missing variable | Renders empty | Message isn’t sent; you get a Rendering Failure event |
if, else, unless, each, nested paths |
Yes | Yes, plus inline partials |
formatDate, insert, equals, notEquals, greaterThan, lessThan, and, or, length |
SendGrid helpers | Not available. Format in code, pass booleans |
| Size | — | 500 KB per template, 20,000 per region |
aws sesv2 create-email-template --cli-input-json file://password-reset.json
aws sesv2 test-render-email-template --template-name password-reset \
--template-data '{"name":"Ana","resetUrl":"https://example.com/r/abc"}'
If your templates rely heavily on SendGrid helpers, render them in your app with the handlebars package (register equivalent helpers) and send the result as plain HTML. You also get escaping back that way.
5. Swap the code
API. Before:
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
await sgMail.send({
to: 'user@example.org', from: 'Example <noreply@example.com>',
templateId: 'd-0123456789abcdef', dynamicTemplateData: { name: 'Ana', resetUrl },
categories: ['password-reset'],
});
After (npm i @aws-sdk/client-sesv2):
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
const ses = new SESv2Client({ region: 'eu-west-1' });
await ses.send(new SendEmailCommand({
FromEmailAddress: 'Example <noreply@example.com>',
Destination: { ToAddresses: ['user@example.org'] },
Content: { Template: { TemplateName: 'password-reset',
TemplateData: JSON.stringify({ name: 'Ana', resetUrl }) } },
ConfigurationSetName: 'transactional',
EmailTags: [{ Name: 'category', Value: 'password-reset' }],
}));
Credentials come from the standard AWS chain (an IAM role on AWS, otherwise an IAM user). The principal needs ses:SendEmail.
SMTP. Change the host from smtp.sendgrid.net to email-smtp.eu-west-1.amazonaws.com, port 587 with STARTTLS. SES SMTP credentials are not your access key pair. Create them under SMTP settings in the SES console, which creates an IAM user, or derive the password from an IAM user’s secret key with AWS’s algorithm. They only work in one region. Replace X-SMTPAPI with X-SES-CONFIGURATION-SET: transactional.
6. IPs and gradual cutover
SES sends from shared IPs by default, which suits most transactional senders. If you had a dedicated IP on SendGrid Pro, you can either lease standard dedicated IPs ($24.95/month each, warmed up by you) or use managed dedicated IPs ($15/month plus a per-email fee, warmed up automatically). You don’t need to warm up shared IPs, but ramp anyway so a mistake stays small:
const pct = Number(process.env.SES_PERCENT ?? 0); // 5 → 25 → 50 → 100
const useSes = (hash(recipient) % 100) < pct; // stable per recipient
Hold each step for a day or two. Watch the bounce and complaint rates in the SES console. AWS puts accounts under review at 5% bounces or 0.1% complaints.
7. Verify
Send every template to seed mailboxes at Gmail, Outlook.com, Yahoo and your own corporate domain. Also send to success@simulator.amazonses.com, bounce@… and complaint@… to check that the events reach your handler. In the received headers, look for:
Authentication-Results: ... dkim=pass header.i=@example.com;
spf=pass smtp.mailfrom=...@bounce.example.com; dmarc=pass header.from=example.com
Also check that click links point to click.example.com and that a suppressed address produces an OnAccountSuppressionList bounce.
8. Rollback plan
Keep the SendGrid account, API key, plan, and every SendGrid DNS record (em…, s1/s2._domainkey, link branding) for at least one billing cycle. Rolling back means setting SES_PERCENT=0. SendGrid never saw the bounces and complaints that happened on SES, so record them in your own database (your event handler already sees them) and check that list before sending through SendGrid again.
9. Clean up
- After the rollback window, delete SendGrid’s domain authentication and link-branding CNAMEs, and remove
include:sendgrid.netfrom your root SPF record if you added it. - Revoke the SendGrid API keys, then downgrade and cancel the plan.
- Set CloudWatch alarms on
Reputation.BounceRateandReputation.ComplaintRate, so you hear about problems before AWS’s review does.
Found a command or version mismatch? Report a correction with the version and steps to reproduce it. Remove secrets and customer data first.