SparkPost (Bird) to AWS SES Migration
Migrating from SparkPost (Bird) to AWS SES
SparkPost was acquired by MessageBird and rebranded as Bird. Many teams are using the disruption as an opportunity to re-evaluate their email infrastructure. This guide covers the migration to AWS SES — pricing, API mapping, analytics alternatives, and what you will need to build.
SparkPost was acquired by MessageBird in 2021 and rebranded as Bird in 2023. For many teams, the acquisition is a forcing function: if you chose SparkPost for focused email infrastructure and now find yourself on a multichannel platform with a different pricing model and product direction, it is a reasonable time to re-evaluate. AWS SES offers significantly lower costs and native AWS integration for teams already in the AWS ecosystem.
## The Acquisition Context
SparkPost was built by the engineering team behind Message Systems (PowerMTA), which was the gold standard in high-volume email infrastructure. The technical foundation is strong. The organizational disruption is real. Bird (the combined entity) is building toward a unified multichannel messaging platform — email, SMS, WhatsApp, push notifications. For teams that chose SparkPost specifically for email deliverability and want an email-focused infrastructure provider, the fit has changed.
## Pricing: Bird vs AWS SES
Bird's pricing structure is more complex post-acquisition and includes platform fees that go beyond per-email costs.
| Volume | Bird / SparkPost | AWS SES | Monthly Savings |
| ---------------- | ------------------------- | ------------- | --------------- |
| 10,000 emails | ~$45/month (starter plan) | $1.00/month | $44.00 |
| 50,000 emails | ~$45/month (included) | $5.00/month | $40.00 |
| 100,000 emails | ~$85/month | $10.00/month | $75.00 |
| 500,000 emails | ~$250/month | $50.00/month | $200.00 |
| 1,000,000 emails | ~$450/month | $100.00/month | $350.00 |
SES charges $0.10 per 1,000 emails. Bird charges vary based on plan tier and include platform fees for the multichannel product whether you use those features or not. At high volume, SES is consistently 4–9x cheaper.
## API Migration: SparkPost Transmission API → SES
SparkPost's Transmission API and SES SendEmail API are structurally different but functionally equivalent.
| SparkPost Transmission API | AWS SES Equivalent | Notes |
| ----------------------------------------- | --------------------------------------------------- | ---------------------------------- |
| `POST /api/v1/transmissions` | `SendEmail` / `SendBulkEmail` | Core send operation |
| `recipients[]` with `address` | `Destination.ToAddresses[]` | Direct mapping |
| `content.from`, `content.subject` | `Source`, `Message.Subject` | Same fields |
| `content.html`, `content.text` | `Message.Body.Html`, `Message.Body.Text` | Direct mapping |
| `substitution_data` (template vars) | Application-rendered HTML or SES template variables | Move rendering to app layer |
| `options.click_tracking`, `open_tracking` | Configuration Set with tracking enabled | Same capability, config-level |
| `campaign_id`, `description` | Configuration Set tags, message tags | For event filtering and CloudWatch |
| REST API key auth | IAM access key + secret or SES SMTP credentials | IAM preferred for AWS-native apps |
| SMTP (smtp.sparkpostmail.com, port 587) | `email-smtp.[region].amazonaws.com`, port 587 | Drop-in SMTP credential swap |
For bulk sends to large recipient lists, map SparkPost's `recipients[]` array to SES `SendBulkEmail` with a `Destinations[]` array. The SES bulk send API supports up to 50 destinations per call; loop and batch for larger lists.
## SparkPost Signals → SES + Custom Analytics
SparkPost Signals is one of the platform's strongest differentiators. It provides:
- Aggregate engagement metrics (open rate, click rate, bounce rate by campaign, domain, sending IP)
- Engagement-based suppression (automatic suppression of chronically unengaged recipients)
- Spam trap monitoring
- A/B testing for subject lines and content
SES does not have an equivalent analytics product. You build it from the event stream.
**Recommended architecture to replicate Signals:**
| Signals Feature | SES Equivalent Architecture | Build Effort |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------- |
| Aggregate engagement metrics (sends, opens, clicks) | SNS events → Lambda → DynamoDB aggregates → CloudWatch custom metrics | Medium |
| Per-campaign open/click rates | Tag messages with ConfigurationSet tags → filter SNS events by tag → aggregate in DynamoDB | Medium |
| Engagement-based suppression | Lambda pre-send check: query DynamoDB for last open/click timestamp → skip if >90 days | Medium–High |
| Bounce rate by domain | SNS Bounce events → Lambda → group by recipient domain in DynamoDB → CloudWatch metric | Medium |
| Spam trap monitoring | No direct equivalent — use third-party inbox monitoring (250ok, GlockApps, or Validity) | External tool |
| A/B testing | Application-layer split: send variant A to 50% of recipients, variant B to other 50%; tag with ConfigurationSet tags | Application-layer |
| Unengaged recipient suppression | Scheduled Lambda: query DynamoDB for recipients with no open/click in 90+ days → add to suppression list | Medium–High |
| Deliverability dashboard | QuickSight or Grafana dashboard over aggregated DynamoDB/S3 data | High (one-time) |
## Subaccounts and Sending Streams: SparkPost → SES Configuration Sets and IAM
SparkPost subaccounts are one of the platform's strongest enterprise features — separate API keys, isolated quotas, isolated reporting, and (on Enterprise) isolated IP pools. SES does not have a "subaccount" concept, but you can replicate the model by combining Configuration Sets, IAM, and dedicated IP pools.
| SparkPost subaccount feature | SES equivalent |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Subaccount API key | Per-subaccount IAM role with `ses:SendEmail` scoped via condition keys on `ses:FromAddress` or tags |
| Per-subaccount sending quota | Per-Configuration-Set sending throttle implemented in application code; SES-level account quota enforced globally |
| Per-subaccount reporting | Per-Configuration-Set tagging, separate event destinations, separate Athena views |
| Per-subaccount IP pool (Enterprise) | Per-Configuration-Set dedicated IP pool |
| Subaccount-level suppression | Application-layer suppression keyed on `(subaccount_id, recipient)` — SES account suppression is global |
The most important consequence: SES account-level suppression is shared across all your sending streams. If subaccount A's marketing complaint causes a hard suppression, subaccount B's transactional sends to the same address are also suppressed. If the subaccount model in SparkPost was specifically about per-tenant suppression isolation (e.g., a multi-tenant SaaS where one customer's bad list cannot block another customer's sends), application-layer suppression keyed on tenant is the only way to preserve that semantic on SES.
## Templates: SparkPost Substitution Data → Application-Layer Rendering
SparkPost templates support Mustache-style substitution (`{{variable}}`) plus conditional blocks (`{{#if condition}} ... {{/if}}`), looping (`{{#each items}}`), and stored snippets. SES templates support only basic variable substitution with no conditionals or loops, and most teams skip the SES template feature entirely after migration.
The recommended pattern is to move template rendering to application code:
- **React Email** for JavaScript and TypeScript projects
- **MJML + Handlebars** for framework-agnostic templating
- **Liquid** for Ruby-shop teams that already use it elsewhere
Migration steps:
1. Export each SparkPost stored template via `GET /api/v1/templates/{id}` — capture both the `html` and `text` parts.
2. Translate the SparkPost-specific syntax to your chosen template engine. Most syntactic constructs map cleanly; the pieces that need attention are:
- `{{render_dynamic_content(...)}}` — replace with conditional logic in code
- `{{snippet(...)}}` — replace with shared component imports
- Substitution data passed via `substitution_data` becomes function arguments or React props
3. Re-test rendered output across major email clients. Outlook on Windows is the client most likely to break a SparkPost-exported template because of its idiosyncratic CSS rendering.
4. Add the template to version control next to the code that triggers it.
Two engineering wins from moving template rendering to application code: types catch missing variables at build time, and templates can be previewed locally without sending real mail.
## Step-by-Step SparkPost → SES Migration
A clean SparkPost-to-SES cutover for a transactional product domain takes one to three weeks. A full migration including Signals analytics replication, subaccount isolation, and stored-template porting takes four to eight weeks.
**Phase 1 — Inventory and SES setup (Week 1)**
1. Catalog every SparkPost feature in active use — Transmission API, SMTP, stored templates, subaccounts, webhooks, Signals, A/B testing, suppressions.
2. Verify the sending domain in SES. Publish DKIM, SPF, DMARC, and a custom MAIL FROM subdomain. Leave Bird/SparkPost records in place for the parallel-send window.
3. Move out of the SES sandbox by submitting a production access request. Expect 24–48 hours.
4. Create one Configuration Set per send category — `transactional`, `notifications`, `marketing` — and one per multi-tenant subaccount if applicable. Wire each to a Kinesis Firehose event destination writing to S3.
**Phase 2 — Template and code refactor (Week 1–2)**
1. Export every stored template from SparkPost. Translate to React Email, MJML, or your chosen engine.
2. Wrap every send call site behind a `sendEmail()` helper that accepts a template, recipient, subject, category. Add a feature flag to route by category for phased rollout.
3. Replace SparkPost SDK calls with AWS SDK SES calls. Map `recipients[]` and `substitution_data` to `Destination.ToAddresses[]` and template props.
4. Implement per-send checks: account-level suppression cache, per-tenant suppression for multi-tenant senders, frequency cap, idempotency key.
**Phase 3 — Suppression import (Week 2)**
1. Export hard bounces and complaints from SparkPost via `GET /api/v1/suppression-list` (paginate; lists with millions of entries take time to export).
2. Bulk-load to the SES account-level suppression list via `PutSuppressedDestination`. This is the most-overlooked step in SparkPost migrations and the cause of most early reputation regressions.
3. For multi-tenant senders, mirror the suppression to the application-layer per-tenant store keyed on `(tenant_id, email)`.
**Phase 4 — Signals replacement and event pipeline (Week 2–3)**
1. Subscribe each Configuration Set to Kinesis Firehose with dynamic partitioning by `category/year/month/day/hour`.
2. Build the Lambda + DynamoDB aggregation pipeline described in the Signals architecture table above for engagement metrics, per-domain bounce rates, and engagement-based suppression.
3. Stand up the smallest viable replacement for the Signals dashboard: a Lambda or Node.js API that queries Athena over Firehose-written S3 data.
4. Migrate Signals A/B test logic to application-layer split sending tagged with Configuration Set tags.
**Phase 5 — Cutover and decommission (Week 3+)**
1. Flip the feature flag for transactional traffic. Keep SparkPost running for 24–48 hours as fast rollback.
2. Watch CloudWatch dashboards and SparkPost reports in parallel. Investigate any divergence immediately.
3. After 7 clean days, scale down SparkPost plan, rotate API keys, remove SparkPost's SPF include from DNS.
4. After 30 clean days, close the SparkPost account.
## Common Migration Challenges
**Webhook batching mismatch.** SparkPost webhooks deliver batches of events in a single POST — up to 1MB or 1,000 events per delivery. SES via SNS delivers one event per notification. Code that processed SparkPost webhook batches by iterating an `events[]` array needs refactoring to handle one-event-per-invocation Lambdas. The throughput difference is rarely an issue (Lambda concurrency handles it), but the parsing code is unrecognizable across the two patterns.
**Substitution data with engagement tracking.** SparkPost can rewrite links in a template with per-recipient tracking parameters derived from `substitution_data`. SES tracking redirects work the same way at the Configuration Set level but do not interpolate per-recipient template variables into the redirect URL. If your template uses `<a href="{{cta_url}}?email={{address}}">`, the personalization happens at the application render layer, not at SES tracking time.
**A/B testing logic.** SparkPost's A/B testing is a platform feature that splits sends across variants and reports on engagement per variant. SES has no equivalent. Move A/B logic to application code: split the recipient list at send time (50/50, 25/75, etc.), tag each send's Configuration Set with the variant name, and aggregate engagement metrics by tag from the event pipeline. The reporting is more work than SparkPost's built-in but is more flexible — you can multivariate-test arbitrary template, send-time, or sender-name variants.
**Subaccount-scoped credentials.** SparkPost subaccount API keys are scoped to a single subaccount. SES uses IAM, which can be scoped per-application but not per-tenant out of the box. For multi-tenant senders, the cleanest pattern is one IAM role per tenant tier with `ses:SendEmail` allowed only for `Tags/tenant_id` matching the role's tenant scope, plus application-layer enforcement before the SES call. This is more setup than SparkPost subaccounts but produces equivalent isolation.
**Signals engagement-based suppression timing.** SparkPost suppresses unengaged recipients automatically once Signals decides they are dead weight. The first 60 days after migration, your SES sending list still contains every address Signals would have suppressed because the data lives in SparkPost, not in your application. Export Signals engagement history before cutover and use it to prepopulate the engagement-based suppression layer in the new contact store.
**Apple MPP open inflation.** SparkPost reports already filter some MPP traffic in Signals. After migration to SES with raw event capture, you see every prefetch event individually. Filter MPP opens (identifiable by the `User-Agent: Mail/MPP` and Apple-owned IP ranges) before feeding engagement data into the engagement-based suppression layer.
**SMTP credential format.** SparkPost SMTP uses the literal string `SMTP_Injection` as the username. SES SMTP uses a generated SMTP user (looks like `AKIA...`) and password derived from an IAM user via the SES console or the `convert_iam_to_smtp_password` algorithm. The SDK and CLI handle this; if you have legacy infrastructure using the literal `SMTP_Injection`, plan for a hard credential change.
## Deliverability Discipline After the Cutover
SparkPost's underlying infrastructure is strong — built by the team behind Message Systems / PowerMTA — and inherits good defaults. SES exposes the same control surface but does not opinionate. Three operational habits separate teams that maintain SparkPost-grade placement on SES from teams that watch placement decay over six months.
**Stream isolation through Configuration Sets and IP pools.** Run separate Configuration Sets — and ideally separate dedicated IP pools — for transactional, product activity, and marketing traffic. SparkPost gave you this through subaccounts and binding groups; SES gives you the same isolation through Configuration Sets pointing to dedicated IP pools. A complaint spike on a marketing broadcast cannot reach password-reset deliverability if the IPs are isolated.
**Engagement-based send eligibility.** SparkPost Signals did this for you automatically. On SES you build it: maintain a `last_engaged_at` timestamp per recipient updated nightly from open and click events, and suppress recipients with no engagement in 90 days from marketing streams (180 days for transactional). Gmail and Microsoft weight recent positive engagement heavily; a smaller, hotter list lifts placement for the entire domain.
**Per-domain placement testing.** Send to a small panel of monitored seed inboxes (Gmail, Outlook, Yahoo, iCloud) on every major broadcast. Inbox vs. promotions vs. spam folder placement is your earliest warning system — much earlier than the bounce rate metric, which only spikes after reputation damage is already done. SparkPost's enterprise tier had seed-list testing built in; on SES it is a small tool you stand up yourself or buy from a third party (250ok, GlockApps, Validity).
**Authentication discipline.** Gmail and Yahoo's 2024 sender requirements treat unauthenticated mail above 5,000 messages per day as effectively undeliverable in 2026. Publish SPF (`include:amazonses.com`), enable Easy DKIM, advance DMARC from `p=none` to `p=quarantine` to `p=reject` over four to six weeks of clean aggregate reports, set a custom MAIL FROM subdomain for explicit SPF alignment, and add BIMI once enforcement is stable and you have a Verified Mark Certificate.
## Production Event Pipeline: Replicating Signals at Scale
The Signals replacement table earlier in this guide covers the per-feature mapping. The full production pipeline that delivers Signals-grade analytics on SES is:
```
SES Configuration Set
↓ (event destination)
Kinesis Data Firehose
↓ (60-second buffer or 5 MB)
S3 (Parquet, partitioned by year/month/day/category)
↓
Athena ← Node.js API ← Signals-style dashboard / suppression service / alerts
```
Subscribe each Configuration Set to a Firehose delivery stream with dynamic partitioning. Lifecycle the bucket: Standard for 30 days (hot analytics window), Standard-IA at 30 days, Glacier Flexible Retrieval at 180 days. Raw events are the cheapest part of the stack and the most useful during deliverability investigations.
The Node.js API exposes the endpoints SparkPost users miss most:
- `GET /messages?recipient=foo@bar.com` — Signals-style activity log
- `GET /campaigns/:id/metrics` — open, click, bounce, complaint by `campaign_id` Configuration Set tag
- `GET /deliverability?domain=gmail.com&days=7` — per-receiver placement signals (Signals' engagement health by domain)
- `GET /bounces?subType=Suppressed&days=1` — operational alerting feed
- `GET /engagement/:recipient` — per-recipient engagement history for engagement-based suppression decisions
- `POST /webhooks/slack` — bounce/complaint fan-out
**Filter automated traffic before the data hits engagement logic.** Apple MPP, Microsoft Defender link scanning, Gmail image proxies, and corporate security gateways all generate engagement events that have nothing to do with a human reading the message. Without filtering, your "open rate" double-counts machine activity and engagement-based suppression — the feature SparkPost users miss most after migration — gradually suppresses real subscribers. Tools that score SES events for human vs. automated activity sit naturally between the Firehose stream and the API layer; the bot vs. human signal is what makes the engagement-based suppression layer actually accurate.
## Related Comparisons
Explore other technical comparisons:
- [SendGrid to AWS SES](/compare/sendgrid-to-aws-ses/)
- [Mailgun to AWS SES](/compare/mailgun-to-aws-ses/)
- [Postmark to AWS SES](/compare/postmark-to-aws-ses/)
- [Resend to AWS SES](/compare/resend-to-aws-ses/)
- [Elastic Email to AWS SES](/compare/elastic-email-to-aws-ses/)
## Why Choose FactualMinds for Your Email Migration
FactualMinds is an **AWS Select Tier Consulting Partner** specializing in email infrastructure migration. We have executed SendGrid, Mailgun, Postmark, and SparkPost to AWS SES migrations and know exactly where teams get stuck.
- **Email migration experts** — we handle domain verification, DKIM, bounce architecture, IP warming
- **Assessment-first approach** — we map your current state before writing a line of infrastructure code
- **Zero-downtime cutover planning included** — no failed deliveries during migration
- **AWS Select Tier Partner** — [verified on AWS Partner Network](https://partners.amazonaws.com/partners/001aq000008su2EAAQ/Factual%20Minds)
---SparkPost was acquired by MessageBird in 2021 and rebranded as Bird in 2023. For many teams, the acquisition is a forcing function: if you chose SparkPost for focused email infrastructure and now find yourself on a multichannel platform with a different pricing model and product direction, it is a reasonable time to re-evaluate. AWS SES offers significantly lower costs and native AWS integration for teams already in the AWS ecosystem.
The Acquisition Context
SparkPost was built by the engineering team behind Message Systems (PowerMTA), which was the gold standard in high-volume email infrastructure. The technical foundation is strong. The organizational disruption is real. Bird (the combined entity) is building toward a unified multichannel messaging platform — email, SMS, WhatsApp, push notifications. For teams that chose SparkPost specifically for email deliverability and want an email-focused infrastructure provider, the fit has changed.
Pricing: Bird vs AWS SES
Bird’s pricing structure is more complex post-acquisition and includes platform fees that go beyond per-email costs.
| Volume | Bird / SparkPost | AWS SES | Monthly Savings |
|---|---|---|---|
| 10,000 emails | ~$45/month (starter plan) | $1.00/month | $44.00 |
| 50,000 emails | ~$45/month (included) | $5.00/month | $40.00 |
| 100,000 emails | ~$85/month | $10.00/month | $75.00 |
| 500,000 emails | ~$250/month | $50.00/month | $200.00 |
| 1,000,000 emails | ~$450/month | $100.00/month | $350.00 |
SES charges $0.10 per 1,000 emails. Bird charges vary based on plan tier and include platform fees for the multichannel product whether you use those features or not. At high volume, SES is consistently 4–9x cheaper.
API Migration: SparkPost Transmission API → SES
SparkPost’s Transmission API and SES SendEmail API are structurally different but functionally equivalent.
| SparkPost Transmission API | AWS SES Equivalent | Notes |
|---|---|---|
POST /api/v1/transmissions | SendEmail / SendBulkEmail | Core send operation |
recipients[] with address | Destination.ToAddresses[] | Direct mapping |
content.from, content.subject | Source, Message.Subject | Same fields |
content.html, content.text | Message.Body.Html, Message.Body.Text | Direct mapping |
substitution_data (template vars) | Application-rendered HTML or SES template variables | Move rendering to app layer |
options.click_tracking, open_tracking | Configuration Set with tracking enabled | Same capability, config-level |
campaign_id, description | Configuration Set tags, message tags | For event filtering and CloudWatch |
| REST API key auth | IAM access key + secret or SES SMTP credentials | IAM preferred for AWS-native apps |
| SMTP (smtp.sparkpostmail.com, port 587) | email-smtp.[region].amazonaws.com, port 587 | Drop-in SMTP credential swap |
For bulk sends to large recipient lists, map SparkPost’s recipients[] array to SES SendBulkEmail with a Destinations[] array. The SES bulk send API supports up to 50 destinations per call; loop and batch for larger lists.
SparkPost Signals → SES + Custom Analytics
SparkPost Signals is one of the platform’s strongest differentiators. It provides:
- Aggregate engagement metrics (open rate, click rate, bounce rate by campaign, domain, sending IP)
- Engagement-based suppression (automatic suppression of chronically unengaged recipients)
- Spam trap monitoring
- A/B testing for subject lines and content
SES does not have an equivalent analytics product. You build it from the event stream.
Recommended architecture to replicate Signals:
| Signals Feature | SES Equivalent Architecture | Build Effort |
|---|---|---|
| Aggregate engagement metrics (sends, opens, clicks) | SNS events → Lambda → DynamoDB aggregates → CloudWatch custom metrics | Medium |
| Per-campaign open/click rates | Tag messages with ConfigurationSet tags → filter SNS events by tag → aggregate in DynamoDB | Medium |
| Engagement-based suppression | Lambda pre-send check: query DynamoDB for last open/click timestamp → skip if >90 days | Medium–High |
| Bounce rate by domain | SNS Bounce events → Lambda → group by recipient domain in DynamoDB → CloudWatch metric | Medium |
| Spam trap monitoring | No direct equivalent — use third-party inbox monitoring (250ok, GlockApps, or Validity) | External tool |
| A/B testing | Application-layer split: send variant A to 50% of recipients, variant B to other 50%; tag with ConfigurationSet tags | Application-layer |
| Unengaged recipient suppression | Scheduled Lambda: query DynamoDB for recipients with no open/click in 90+ days → add to suppression list | Medium–High |
| Deliverability dashboard | QuickSight or Grafana dashboard over aggregated DynamoDB/S3 data | High (one-time) |
Subaccounts and Sending Streams: SparkPost → SES Configuration Sets and IAM
SparkPost subaccounts are one of the platform’s strongest enterprise features — separate API keys, isolated quotas, isolated reporting, and (on Enterprise) isolated IP pools. SES does not have a “subaccount” concept, but you can replicate the model by combining Configuration Sets, IAM, and dedicated IP pools.
| SparkPost subaccount feature | SES equivalent |
|---|---|
| Subaccount API key | Per-subaccount IAM role with ses:SendEmail scoped via condition keys on ses:FromAddress or tags |
| Per-subaccount sending quota | Per-Configuration-Set sending throttle implemented in application code; SES-level account quota enforced globally |
| Per-subaccount reporting | Per-Configuration-Set tagging, separate event destinations, separate Athena views |
| Per-subaccount IP pool (Enterprise) | Per-Configuration-Set dedicated IP pool |
| Subaccount-level suppression | Application-layer suppression keyed on (subaccount_id, recipient) — SES account suppression is global |
The most important consequence: SES account-level suppression is shared across all your sending streams. If subaccount A’s marketing complaint causes a hard suppression, subaccount B’s transactional sends to the same address are also suppressed. If the subaccount model in SparkPost was specifically about per-tenant suppression isolation (e.g., a multi-tenant SaaS where one customer’s bad list cannot block another customer’s sends), application-layer suppression keyed on tenant is the only way to preserve that semantic on SES.
Templates: SparkPost Substitution Data → Application-Layer Rendering
SparkPost templates support Mustache-style substitution ({{variable}}) plus conditional blocks ({{#if condition}} ... {{/if}}), looping ({{#each items}}), and stored snippets. SES templates support only basic variable substitution with no conditionals or loops, and most teams skip the SES template feature entirely after migration.
The recommended pattern is to move template rendering to application code:
- React Email for JavaScript and TypeScript projects
- MJML + Handlebars for framework-agnostic templating
- Liquid for Ruby-shop teams that already use it elsewhere
Migration steps:
- Export each SparkPost stored template via
GET /api/v1/templates/{id}— capture both thehtmlandtextparts. - Translate the SparkPost-specific syntax to your chosen template engine. Most syntactic constructs map cleanly; the pieces that need attention are:
{{render_dynamic_content(...)}}— replace with conditional logic in code{{snippet(...)}}— replace with shared component imports- Substitution data passed via
substitution_databecomes function arguments or React props
- Re-test rendered output across major email clients. Outlook on Windows is the client most likely to break a SparkPost-exported template because of its idiosyncratic CSS rendering.
- Add the template to version control next to the code that triggers it.
Two engineering wins from moving template rendering to application code: types catch missing variables at build time, and templates can be previewed locally without sending real mail.
Step-by-Step SparkPost → SES Migration
A clean SparkPost-to-SES cutover for a transactional product domain takes one to three weeks. A full migration including Signals analytics replication, subaccount isolation, and stored-template porting takes four to eight weeks.
Phase 1 — Inventory and SES setup (Week 1)
- Catalog every SparkPost feature in active use — Transmission API, SMTP, stored templates, subaccounts, webhooks, Signals, A/B testing, suppressions.
- Verify the sending domain in SES. Publish DKIM, SPF, DMARC, and a custom MAIL FROM subdomain. Leave Bird/SparkPost records in place for the parallel-send window.
- Move out of the SES sandbox by submitting a production access request. Expect 24–48 hours.
- Create one Configuration Set per send category —
transactional,notifications,marketing— and one per multi-tenant subaccount if applicable. Wire each to a Kinesis Firehose event destination writing to S3.
Phase 2 — Template and code refactor (Week 1–2)
- Export every stored template from SparkPost. Translate to React Email, MJML, or your chosen engine.
- Wrap every send call site behind a
sendEmail()helper that accepts a template, recipient, subject, category. Add a feature flag to route by category for phased rollout. - Replace SparkPost SDK calls with AWS SDK SES calls. Map
recipients[]andsubstitution_datatoDestination.ToAddresses[]and template props. - Implement per-send checks: account-level suppression cache, per-tenant suppression for multi-tenant senders, frequency cap, idempotency key.
Phase 3 — Suppression import (Week 2)
- Export hard bounces and complaints from SparkPost via
GET /api/v1/suppression-list(paginate; lists with millions of entries take time to export). - Bulk-load to the SES account-level suppression list via
PutSuppressedDestination. This is the most-overlooked step in SparkPost migrations and the cause of most early reputation regressions. - For multi-tenant senders, mirror the suppression to the application-layer per-tenant store keyed on
(tenant_id, email).
Phase 4 — Signals replacement and event pipeline (Week 2–3)
- Subscribe each Configuration Set to Kinesis Firehose with dynamic partitioning by
category/year/month/day/hour. - Build the Lambda + DynamoDB aggregation pipeline described in the Signals architecture table above for engagement metrics, per-domain bounce rates, and engagement-based suppression.
- Stand up the smallest viable replacement for the Signals dashboard: a Lambda or Node.js API that queries Athena over Firehose-written S3 data.
- Migrate Signals A/B test logic to application-layer split sending tagged with Configuration Set tags.
Phase 5 — Cutover and decommission (Week 3+)
- Flip the feature flag for transactional traffic. Keep SparkPost running for 24–48 hours as fast rollback.
- Watch CloudWatch dashboards and SparkPost reports in parallel. Investigate any divergence immediately.
- After 7 clean days, scale down SparkPost plan, rotate API keys, remove SparkPost’s SPF include from DNS.
- After 30 clean days, close the SparkPost account.
Common Migration Challenges
Webhook batching mismatch. SparkPost webhooks deliver batches of events in a single POST — up to 1MB or 1,000 events per delivery. SES via SNS delivers one event per notification. Code that processed SparkPost webhook batches by iterating an events[] array needs refactoring to handle one-event-per-invocation Lambdas. The throughput difference is rarely an issue (Lambda concurrency handles it), but the parsing code is unrecognizable across the two patterns.
Substitution data with engagement tracking. SparkPost can rewrite links in a template with per-recipient tracking parameters derived from substitution_data. SES tracking redirects work the same way at the Configuration Set level but do not interpolate per-recipient template variables into the redirect URL. If your template uses <a href="{{cta_url}}?email={{address}}">, the personalization happens at the application render layer, not at SES tracking time.
A/B testing logic. SparkPost’s A/B testing is a platform feature that splits sends across variants and reports on engagement per variant. SES has no equivalent. Move A/B logic to application code: split the recipient list at send time (50/50, 25/75, etc.), tag each send’s Configuration Set with the variant name, and aggregate engagement metrics by tag from the event pipeline. The reporting is more work than SparkPost’s built-in but is more flexible — you can multivariate-test arbitrary template, send-time, or sender-name variants.
Subaccount-scoped credentials. SparkPost subaccount API keys are scoped to a single subaccount. SES uses IAM, which can be scoped per-application but not per-tenant out of the box. For multi-tenant senders, the cleanest pattern is one IAM role per tenant tier with ses:SendEmail allowed only for Tags/tenant_id matching the role’s tenant scope, plus application-layer enforcement before the SES call. This is more setup than SparkPost subaccounts but produces equivalent isolation.
Signals engagement-based suppression timing. SparkPost suppresses unengaged recipients automatically once Signals decides they are dead weight. The first 60 days after migration, your SES sending list still contains every address Signals would have suppressed because the data lives in SparkPost, not in your application. Export Signals engagement history before cutover and use it to prepopulate the engagement-based suppression layer in the new contact store.
Apple MPP open inflation. SparkPost reports already filter some MPP traffic in Signals. After migration to SES with raw event capture, you see every prefetch event individually. Filter MPP opens (identifiable by the User-Agent: Mail/MPP and Apple-owned IP ranges) before feeding engagement data into the engagement-based suppression layer.
SMTP credential format. SparkPost SMTP uses the literal string SMTP_Injection as the username. SES SMTP uses a generated SMTP user (looks like AKIA...) and password derived from an IAM user via the SES console or the convert_iam_to_smtp_password algorithm. The SDK and CLI handle this; if you have legacy infrastructure using the literal SMTP_Injection, plan for a hard credential change.
Deliverability Discipline After the Cutover
SparkPost’s underlying infrastructure is strong — built by the team behind Message Systems / PowerMTA — and inherits good defaults. SES exposes the same control surface but does not opinionate. Three operational habits separate teams that maintain SparkPost-grade placement on SES from teams that watch placement decay over six months.
Stream isolation through Configuration Sets and IP pools. Run separate Configuration Sets — and ideally separate dedicated IP pools — for transactional, product activity, and marketing traffic. SparkPost gave you this through subaccounts and binding groups; SES gives you the same isolation through Configuration Sets pointing to dedicated IP pools. A complaint spike on a marketing broadcast cannot reach password-reset deliverability if the IPs are isolated.
Engagement-based send eligibility. SparkPost Signals did this for you automatically. On SES you build it: maintain a last_engaged_at timestamp per recipient updated nightly from open and click events, and suppress recipients with no engagement in 90 days from marketing streams (180 days for transactional). Gmail and Microsoft weight recent positive engagement heavily; a smaller, hotter list lifts placement for the entire domain.
Per-domain placement testing. Send to a small panel of monitored seed inboxes (Gmail, Outlook, Yahoo, iCloud) on every major broadcast. Inbox vs. promotions vs. spam folder placement is your earliest warning system — much earlier than the bounce rate metric, which only spikes after reputation damage is already done. SparkPost’s enterprise tier had seed-list testing built in; on SES it is a small tool you stand up yourself or buy from a third party (250ok, GlockApps, Validity).
Authentication discipline. Gmail and Yahoo’s 2024 sender requirements treat unauthenticated mail above 5,000 messages per day as effectively undeliverable in 2026. Publish SPF (include:amazonses.com), enable Easy DKIM, advance DMARC from p=none to p=quarantine to p=reject over four to six weeks of clean aggregate reports, set a custom MAIL FROM subdomain for explicit SPF alignment, and add BIMI once enforcement is stable and you have a Verified Mark Certificate.
Production Event Pipeline: Replicating Signals at Scale
The Signals replacement table earlier in this guide covers the per-feature mapping. The full production pipeline that delivers Signals-grade analytics on SES is:
SES Configuration Set
↓ (event destination)
Kinesis Data Firehose
↓ (60-second buffer or 5 MB)
S3 (Parquet, partitioned by year/month/day/category)
↓
Athena ← Node.js API ← Signals-style dashboard / suppression service / alertsSubscribe each Configuration Set to a Firehose delivery stream with dynamic partitioning. Lifecycle the bucket: Standard for 30 days (hot analytics window), Standard-IA at 30 days, Glacier Flexible Retrieval at 180 days. Raw events are the cheapest part of the stack and the most useful during deliverability investigations.
The Node.js API exposes the endpoints SparkPost users miss most:
GET /messages?recipient=foo@bar.com— Signals-style activity logGET /campaigns/:id/metrics— open, click, bounce, complaint bycampaign_idConfiguration Set tagGET /deliverability?domain=gmail.com&days=7— per-receiver placement signals (Signals’ engagement health by domain)GET /bounces?subType=Suppressed&days=1— operational alerting feedGET /engagement/:recipient— per-recipient engagement history for engagement-based suppression decisionsPOST /webhooks/slack— bounce/complaint fan-out
Filter automated traffic before the data hits engagement logic. Apple MPP, Microsoft Defender link scanning, Gmail image proxies, and corporate security gateways all generate engagement events that have nothing to do with a human reading the message. Without filtering, your “open rate” double-counts machine activity and engagement-based suppression — the feature SparkPost users miss most after migration — gradually suppresses real subscribers. Tools that score SES events for human vs. automated activity sit naturally between the Firehose stream and the API layer; the bot vs. human signal is what makes the engagement-based suppression layer actually accurate.
Related Comparisons
Explore other technical comparisons:
Why Choose FactualMinds for Your Email Migration
FactualMinds is an AWS Select Tier Consulting Partner specializing in email infrastructure migration. We have executed SendGrid, Mailgun, Postmark, and SparkPost to AWS SES migrations and know exactly where teams get stuck.
- Email migration experts — we handle domain verification, DKIM, bounce architecture, IP warming
- Assessment-first approach — we map your current state before writing a line of infrastructure code
- Zero-downtime cutover planning included — no failed deliveries during migration
- AWS Select Tier Partner — verified on AWS Partner Network
Frequently Asked Questions
What happened to SparkPost?
Is Bird (SparkPost) good for transactional email?
How do I migrate from SparkPost to AWS SES?
Does AWS SES have analytics like SparkPost?
Is SparkPost being discontinued?
Need Help Migrating to AWS SES?
FactualMinds is an AWS Select Tier Partner specializing in email infrastructure migration. We handle domain verification, Configuration Set architecture, bounce handling, IP warming, and cutover.
