Migrating from SendGrid to Amazon SES Without Hurting Deliverability
A phased plan to move transactional email from SendGrid to Amazon SES: domain verification, IP warm-up, suppression port, and SNS webhooks, with no deliverability hit.
Moving transactional mail from SendGrid to Amazon SES can cut your bill by an order of magnitude, but a careless cutover tanks inbox placement for days. This guide gives a phased plan — verify, dual-send, warm up, port suppression, rebuild webhooks — that moves volume across without a deliverability dip. It assumes you have already chosen SES per the ESP selection and integration guide.
Problem and scope
The migration covers the transactional send path only: password resets, receipts, verification emails. The risk is not the API change — that is a one-line adapter swap behind a provider-agnostic interface. The risk is reputation. This guide scopes the work to keeping deliverability flat while you shift volume from SendGrid's infrastructure to SES.
Root cause of the risk
Three things make a naive switch dangerous:
- Reputation resets on new sending infrastructure. Mailbox providers (Gmail, Outlook, Yahoo) track reputation per sending domain and per IP. If you move to a fresh SES dedicated IP and blast full volume on day one, you look like a brand-new sender spiking traffic — the classic spam pattern — and land in spam or get rate-limited.
- SES makes you rebuild what SendGrid gave you. SendGrid maintained your suppression list, classified bounces, and POSTed signed event webhooks. SES gives you none of that out of the box: you build the suppression list, you wire bounce/complaint events through SNS, and you manage IP warm-up yourself. The deeper suppression logic lives in bounce and complaint handling.
- Authentication must be re-established on the new infrastructure. SES needs its own DKIM keys published in DNS, and your SPF and DMARC alignment must cover SES before you send. Get this exactly right using the email authentication guide.
The migration plan
1. Verify the domain and DKIM in SES
Create the domain identity in SES and enable Easy DKIM. SES emits three CNAME records; publish them in DNS and wait for SES to report the identity as verified. Add or extend your SPF record to include amazonses.com, and confirm DMARC still aligns. Until this is green, every send fails with MessageRejected.
2. Request production access
A new SES account is in sandbox: capped at 200 recipients/24h, 1 message/second, and only verified destinations. Open the production-access request in the SES console with your use case and expected volume. Do this early — approval can take a day.
3. Dual-send / percentage cutover
Keep SendGrid live and route a small percentage of traffic through SES, ramping up. The provider-agnostic interface makes this a routing decision, not a code change.
// email/router.ts — percentage cutover by stable hash of the recipient.
import { SesAdapter } from './adapters/ses';
import { SendGridAdapter } from './adapters/sendgrid';
import { createHash } from 'crypto';
const ses = new SesAdapter();
const sendgrid = new SendGridAdapter();
const SES_PERCENT = Number(process.env.SES_PERCENT ?? '10'); // ramp via env, no deploy
export function pickSender(recipient: string) {
// Stable bucket 0–99 so a given user always lands on the same provider
// during a phase — avoids split reputation signals for one mailbox.
const bucket = parseInt(createHash('md5').update(recipient).digest('hex').slice(0, 2), 16) % 100;
return bucket < SES_PERCENT ? ses : sendgrid; // SES when in-bucket, else SendGrid
}
4. IP warm-up schedule
If you provision an SES dedicated IP, it has zero reputation. Ramp volume gradually so mailbox providers learn to trust it. A typical schedule doubles daily volume from a low base:
| Day | Max messages/day |
|---|---|
| 1–2 | 50 |
| 3–4 | 100 |
| 5–7 | 500 |
| 8–10 | 2,000 |
| 11–14 | 10,000 |
| 15+ | double daily until target |
Hold or slow the ramp if bounce or complaint rates climb. On the SES shared pool, warm-up is largely handled for you, which is one reason to start there — see the dedicated-vs-shared variant below.
5. Port the suppression list
Export SendGrid's bounce and spam-report suppression lists via its Suppressions API, then load them into your own suppression store and SES's account-level suppression list so you never re-mail an address that already hard-bounced or complained.
// scripts/port-suppressions.ts
import { SESv2Client, PutSuppressedDestinationCommand } from '@aws-sdk/client-sesv2';
const ses = new SESv2Client({ region: process.env.AWS_REGION });
// 1. EXPORT from SendGrid: pull every suppression category so none leaks through.
async function exportFromSendGrid(): Promise<{ email: string; reason: 'BOUNCE' | 'COMPLAINT' }[]> {
const key = process.env.SENDGRID_API_KEY!;
const get = async (path: string) =>
(await fetch(`https://api.sendgrid.com/v3/suppression/${path}`, {
headers: { Authorization: `Bearer ${key}` },
})).json();
// SendGrid splits suppressions across three endpoints — hard bounces, blocks, and spam reports.
const [bounces, blocks, spam] = await Promise.all([get('bounces'), get('blocks'), get('spam_reports')]);
return [
...bounces.map((b: any) => ({ email: b.email, reason: 'BOUNCE' as const })),
...blocks.map((b: any) => ({ email: b.email, reason: 'BOUNCE' as const })), // blocks → treat as bounce
...spam.map((s: any) => ({ email: s.email, reason: 'COMPLAINT' as const })), // spam_reports → complaint
];
}
// 2. IMPORT into SES account-level suppression so a ported address is never re-mailed.
async function importSuppressions(addresses: { email: string; reason: 'BOUNCE' | 'COMPLAINT' }[]) {
for (const { email, reason } of addresses) {
// SES: account-level suppression blocks sends to known-bad addresses.
await ses.send(new PutSuppressedDestinationCommand({ EmailAddress: email, Reason: reason }));
}
}
// Run order: load your own store first (authoritative), then mirror into SES.
exportFromSendGrid().then(importSuppressions);
Mirror the same list into your own suppression store, not just SES's account-level list, so the gate in your send worker rejects these addresses before any provider call — your store stays authoritative across both providers during the dual-send window.
6. Rebuild webhooks via SNS
SendGrid POSTed a signed Event Webhook. SES instead publishes events to SNS. Create a configuration set with an event destination that publishes bounce, complaint, and delivery events to an SNS topic, then subscribe your HTTPS endpoint.
// SES: attach a config set whose event destination targets an SNS topic.
import { SESv2Client, CreateConfigurationSetEventDestinationCommand } from '@aws-sdk/client-sesv2';
const ses = new SESv2Client({ region: process.env.AWS_REGION });
await ses.send(new CreateConfigurationSetEventDestinationCommand({
ConfigurationSetName: 'transactional',
EventDestinationName: 'sns-events',
EventDestination: {
Enabled: true,
// SES emits these to SNS; your consumer updates suppression + analytics.
MatchingEventTypes: ['BOUNCE', 'COMPLAINT', 'DELIVERY', 'REJECT'],
SnsDestination: { TopicArn: process.env.SNS_TOPIC_ARN! },
},
}));
Your endpoint must confirm the SNS subscription and verify the SNS message signature before trusting payloads — process them as an idempotent webhook consumer so SNS redeliveries do not double-count or double-suppress.
What to watch at each ramp step
The percentage router buys you a controlled experiment: at each volume step, compare SES's bounce rate, complaint rate, and delivery rate against the SendGrid baseline for the same message types. Mailbox providers publish reputation signals you can read — Google Postmaster Tools shows domain and IP reputation, spam rate, and authentication pass rates for Gmail traffic specifically. Hold the ramp if SES's complaint rate drifts above roughly 0.1% or bounces climb, because both are signals that the new infrastructure is being throttled or filtered. Only raise SES_PERCENT once a step has run clean for several days. Resist the urge to jump straight from 10% to 100% the moment the first numbers look fine — reputation builds on sustained, consistent volume, and a sudden spike reads as exactly the anomaly mailbox providers filter on.
Keep the bucketing stable per recipient during a phase. If you re-hash or randomize per send, a single mailbox sees mail alternating between SendGrid and SES infrastructure, which splits the reputation signal for that recipient and slows trust-building on the new path. The md5-of-recipient bucket in the router above pins each address to one provider for the duration of a phase.
Variant: dedicated IP pool vs shared
- SES dedicated IP pool. Choose this for high, steady volume and full reputation isolation from other senders. The cost is the warm-up schedule above and the requirement to maintain consistent volume so the IP stays warm. A dormant dedicated IP cools and must be re-warmed.
- SES shared pool. Choose this for low or spiky volume, or to skip warm-up entirely. You share reputation with other SES senders, which AWS manages, at the cost of less isolation. For most migrations under a few hundred thousand messages a month, start shared and only move to dedicated when volume justifies it.
Pipeline integration
Slot SES into the existing render → enqueue → send → consume-events pipeline behind the same interface SendGrid used. During cutover, both adapters are live and the router decides per message. Once SES carries 100% with healthy metrics, retire the SendGrid credentials but keep the adapter compiled in so a rollback is one env-var flip.
One subtlety during dual-send: events now arrive from two sources in two formats. SendGrid POSTs its Event Webhook, while SES events arrive via SNS. Normalize both into your internal event shape at the edge so suppression and analytics do not care which provider produced the event. Tag each stored event with its provider so that, if a deliverability dip appears, you can attribute it to SES or SendGrid rather than guessing. The same idempotent consumer handles both streams; it just has two ingress adapters, mirroring the send side. Until SES is at 100%, keep your suppression list authoritative across both providers — an address that hard-bounces on SES must also be suppressed for any residual SendGrid traffic, or you will keep mailing a dead address and accrue complaints.
A final caution on the cutover itself: do not delete the SendGrid domain authentication or DNS records the moment SES hits 100%. Leave SendGrid able to send for a grace period in case a rollback is needed, and only remove its DKIM records and API keys after SES has carried full volume cleanly for a couple of weeks. Removing the old path prematurely turns a one-flip rollback into an emergency re-onboarding.
Rollback plan
Every ramp step must be reversible in seconds, because a deliverability dip on SES is something you want to abandon immediately rather than debug under load. The percentage router makes this trivial: rolling back is setting SES_PERCENT back down — to a lower step or to 0 — which is an environment-variable change, not a deploy. Because the bucketing is a stable hash of the recipient, dialing the percentage down moves the marginal buckets back to SendGrid without disturbing the addresses still served by SES, so a partial rollback is clean rather than a thundering re-shuffle of which provider each mailbox sees.
For a rollback to actually work, three things must stay in place until SES is proven at 100% for a couple of weeks: the SendGrid adapter must remain compiled into the build, the SendGrid API key must remain valid, and SendGrid's DKIM/DNS records and domain authentication must not be deleted. Removing any of these prematurely converts a one-flip rollback into an emergency re-onboarding — re-verifying a domain and re-warming infrastructure under pressure is exactly the situation the phased plan exists to avoid. Keep your suppression list authoritative across both providers throughout, so that if you do roll traffic back to SendGrid, an address that hard-bounced on SES is still suppressed for the residual SendGrid sends. Treat the cutover as committed only after a sustained clean run at full volume; until then, the old path is your safety net and the cost of keeping it warm is trivial next to a reputation recovery.
Validation checklist
- SES domain identity verified and Easy DKIM CNAMEs published in DNS
- SPF includes
amazonses.comand DMARC still aligns on the sending domain - SES production access granted (out of sandbox) before raising volume
- Percentage router live and ramping SES via an env var, no deploy needed
- Dedicated IP warm-up schedule followed; ramp paused if bounce/complaint rates rise
- SendGrid suppression list exported and loaded into SES account-level suppression
- SES configuration set publishes bounce/complaint/delivery events to SNS
- Webhook endpoint confirms the SNS subscription and verifies message signatures
- Deliverability metrics flat versus the SendGrid baseline at each ramp step
- SendGrid adapter retained for one-flip rollback until SES is proven at 100%
Related
- ESP selection and integration — the send abstraction that makes this cutover a routing change
- SendGrid vs Postmark vs Amazon SES — confirm SES is the right destination before migrating
- Email authentication: SPF, DKIM, DMARC — re-establishing auth on SES infrastructure
- Bounce and complaint handling — the suppression logic SES makes you own
- Building an idempotent webhook consumer — processing SES events from SNS safely
← Back to ESP Selection & Integration