SV Premium

Migration guides

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

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

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:

{ 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

Found a command or version mismatch? Report a correction with the version and steps to reproduce it. Remove secrets and customer data first.