Postmark to AWS SES Migration
Migrating from Postmark to AWS SES: When and How
Postmark is the best deliverability-focused ESP on the market. SES is 5–10x cheaper at high volume. This guide helps you decide whether the cost savings justify the operational trade-offs — and how to execute the migration if they do.
Postmark is the premium option in transactional email — priced accordingly, and genuinely worth it for teams that prioritize deliverability above all else. AWS SES is a commodity infrastructure service priced at cost. The decision to migrate is fundamentally about whether the cost savings justify building the operational scaffolding that Postmark handles for you.
This guide gives you the honest trade-off analysis and the step-by-step migration path if you decide to move.
## The Cost Difference Is Stark
No other comparison in the transactional email space has this wide a price gap at volume.
| Volume | Postmark | AWS SES | Monthly Savings | Annual Savings |
| ---------------- | ----------- | ------------- | --------------- | -------------- |
| 10,000 emails | $15/month | $1.00/month | $14.00 | $168 |
| 50,000 emails | $50/month | $5.00/month | $45.00 | $540 |
| 125,000 emails | $100/month | $12.50/month | $87.50 | $1,050 |
| 500,000 emails | ~$400/month | $50.00/month | $350.00 | $4,200 |
| 1,000,000 emails | ~$800/month | $100.00/month | $700.00 | $8,400 |
SES dedicated IPs ($24.95/IP/month) add some cost, but even with two dedicated IPs, SES is 3–6x cheaper than Postmark at 500K+ emails per month.
## Message Streams vs SES Configuration Sets
Postmark's message streams are its most distinctive feature. Each stream has its own dedicated IP pool, separate bounce/complaint tracking, and isolated reputation. You create a transactional stream and a broadcast (marketing) stream, and Postmark ensures that a complaint spike on your broadcast stream cannot damage your transactional inbox placement.
SES does not have a native message stream concept, but you can replicate the isolation:
| Postmark Concept | SES Equivalent | Setup Required |
| ------------------------------ | --------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Message stream (transactional) | Dedicated Configuration Set + IP pool | Create Config Set, request dedicated IPs, assign to transactional sends |
| Message stream (broadcast) | Separate Configuration Set + IP pool | Second Config Set with separate dedicated IPs; enforce separation in app code |
| Per-stream bounce tracking | Per-Configuration-Set SNS event destination | Create SNS topic per Config Set; subscribe Lambda to aggregate bounce metrics |
| Per-stream complaint tracking | Per-Configuration-Set SNS event destination | Same SNS topic or separate; Lambda processes complaint events per Config Set |
| Stream-level suppression | Account-level suppression list + custom store | SES account-level list handles hard bounces; application database for stream-level unsubscribes |
| Message stream isolation | IP pool isolation + Config Set enforcement | No automatic enforcement — application must select correct Config Set per send |
## Authentication: SPF, DKIM, DMARC, and BIMI
Postmark configures most authentication for you behind the scenes — SES requires you to publish every record explicitly. Skipping this step is the single most common reason Postmark migrations see deliverability regress in the first 30 days. Get all four right before your first production send.
| Record | What it does | SES setup |
| --------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **SPF** | Authorizes AWS SES IPs to send for your domain. Without it, Gmail and Outlook treat your mail as unauthenticated. | Add `v=spf1 include:amazonses.com ~all` to your sending domain's TXT record. If you also send via Google Workspace or another provider, merge `include:` directives — never publish two SPF records. |
| **DKIM** | Cryptographically signs each message so receivers can verify it was not altered in transit. | Verify the domain in SES → enable Easy DKIM → publish the three CNAME records SES provides. Rotate keys yearly using BYODKIM if you operate under regulatory scope (HIPAA, PCI, SOC 2). |
| **DMARC** | Tells receivers what to do when SPF or DKIM fail and gives you forensic visibility into spoofing attempts. | Start at `p=none` with an `rua=` aggregate-report mailbox, monitor for two to four weeks, then move to `p=quarantine` and finally `p=reject`. Gmail and Yahoo's 2024 sender requirements made enforcement mandatory for bulk senders. |
| **BIMI** | Displays your verified logo next to messages in supporting inboxes (Gmail, Apple Mail, Yahoo). Lifts open rates 5–15%. | Requires `p=quarantine` or `p=reject` DMARC enforcement plus a Verified Mark Certificate (VMC) from Entrust or DigiCert. Publish the SVG-Tiny logo at the URL referenced in your BIMI TXT record. |
**Migration order matters.** Publish SPF and DKIM the moment you verify your SES domain, monitor DMARC aggregate reports for two weeks before enforcement, then layer BIMI once enforcement is stable. Flipping straight to `p=reject` on day one will silently drop legitimate fallback traffic if anything is misconfigured.
## API Migration — Postmark → AWS SES
The biggest operational change is moving from Postmark's HTTP-based API to SES's SDK calls. Here's the mapping:
| Postmark | AWS SES | Migration Notes |
| ------------------------------------ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `POST /email` | `SendEmail()` or `SendTemplatedEmail()` | Postmark accepts JSON; SES uses SDK methods. Use boto3 or AWS SDK for your language. |
| `MessageStream: "transactional"` | `ConfigurationSetName: "transactional"` | Set this on every `SendEmail()` call to route to the right IP pool |
| Template system (Mustache `{{var}}`) | No template engine | **Move rendering to application layer.** Use React Email, MJML, or Handlebars in your app. Pre-render the full HTML, send via SES. |
| `Metadata: { key: value }` | `MessageAttributes` | SES equivalent for custom headers; same pattern for tracking. |
| Webhook pushes to HTTP endpoint | SNS topic subscriptions | SES events → SNS → Lambda → your application. More infrastructure but more control. |
**Key difference:** Postmark templates live in Postmark's UI. SES has no template engine. This forces you to move template logic to your application layer, which is actually cleaner long-term (you control versioning, you can test templates with your code).
## Sending Notification Emails on SES (the Postmark Replacement Pattern)
Notification emails — password resets, account verifications, MFA codes, receipts, payment-failed alerts, comment replies, security warnings — are Postmark's signature use case. Replicating that workflow on SES is straightforward once four patterns are in place: per-category Configuration Sets, event-driven triggering, application-layer template rendering, and idempotent at-least-once delivery.
### 1. Notification taxonomy and Configuration Sets
Postmark gives you message streams; SES gives you Configuration Sets. Treat each notification class as its own Configuration Set so analytics, suppression policy, and IP routing stay isolated.
| Notification class | Examples | SES Configuration Set | IP pool |
| --------------------- | ----------------------------------------------------------- | --------------------- | ----------------------------- |
| Account critical | Password reset, email verification, MFA codes, login alerts | `notif-critical` | Dedicated, transactional-only |
| Billing & receipts | Invoices, receipts, payment failed, subscription renewal | `notif-billing` | Dedicated, transactional-only |
| Product activity | Comment replies, mentions, status changes, exports ready | `notif-activity` | Shared transactional pool |
| Marketing & lifecycle | Newsletters, product announcements, re-engagement | `notif-marketing` | Separate marketing IP pool |
Setting `ConfigurationSetName` on every `SendEmail` call enforces the separation. A bounce on a marketing send never touches password-reset deliverability — provided the right Configuration Set is selected per send.
### 2. Triggering — event-driven, not synchronous
Calling SES directly from a request handler works for one-off mail but breaks under load and gives no retry semantics. Wire notifications through an event bus instead.
```
Application
↓ publishes domain event
EventBridge / SNS
↓ filters and routes
SQS (with DLQ)
↓
Lambda (notification-sender)
↓ suppression check → render template → call SES
AWS SES → recipient
```
What this buys you:
- **Retries are free.** Lambda + SQS DLQ retries failed sends without your application code knowing.
- **Decoupling.** Adding a "weekly digest" notification never touches the request path.
- **Observability.** Every notification flows through one Lambda, one CloudWatch log group, one X-Ray trace.
- **Replay.** EventBridge archives let you re-run the last 24 hours of notifications in staging without re-running business logic.
### 3. Template rendering at the application layer
Postmark templates ship with the platform; SES templates lack conditionals and inheritance. Render in your code using **React Email**, **MJML**, or **Handlebars**, then send the pre-rendered HTML and plain-text bodies through `SendEmail`.
```typescript
import { render } from '@react-email/render';
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import PasswordResetEmail from './emails/password-reset';
const ses = new SESClient({ region: 'us-east-1' });
export async function sendPasswordReset(user, resetUrl) {
const html = render(PasswordResetEmail({ name: user.name, resetUrl }));
const text = render(PasswordResetEmail({ name: user.name, resetUrl }), {
plainText: true,
});
await ses.send(
new SendEmailCommand({
Source: '"Acme Security" <security@acme.com>',
Destination: { ToAddresses: [user.email] },
Message: {
Subject: { Data: 'Reset your password' },
Body: {
Html: { Data: html },
Text: { Data: text },
},
},
ConfigurationSetName: 'notif-critical',
Tags: [
{ Name: 'notification_type', Value: 'password_reset' },
{ Name: 'user_id', Value: user.id },
],
})
);
}
```
Three wins over Postmark-hosted templates:
- **Version control** — templates live next to the code that triggers them.
- **Type safety** — TypeScript catches missing template variables at build time.
- **Local preview** — render in Storybook or a Node script without sending real mail.
### 4. Suppression and preference check before every send
Postmark blocks sends to suppressed addresses automatically. SES blocks at the account level for hard bounces and complaints, but you still need to check **per-notification preferences** before calling `SendEmail`. A defensive sender Lambda runs four checks in order:
1. **Account-level SES suppression** — cached from the `GetSuppressedDestination` API
2. **Application unsubscribe table** — has the user opted out of this category?
3. **Frequency cap** — have we already sent this exact notification in the last N hours?
4. **Quiet hours** — does the user's timezone preference block sends right now?
Checks fail closed. Anything ambiguous defaults to "do not send." An unsent notification is recoverable; an unwanted notification is not.
### 5. Idempotency for at-least-once delivery
EventBridge and SQS both deliver at-least-once. Without an idempotency key, a single password-reset event can produce three emails when retries fire. Derive an idempotency key from `{event_id}:{notification_type}:{user_id}` and write it to DynamoDB with a conditional `attribute_not_exists`:
```typescript
await ddb.send(
new PutItemCommand({
TableName: 'notification-idempotency',
Item: {
idempotency_key: { S: `${eventId}:password_reset:${userId}` },
expires_at: { N: String(Math.floor(Date.now() / 1000) + 86400) },
},
ConditionExpression: 'attribute_not_exists(idempotency_key)',
})
);
```
If the conditional check fails, log and exit — the email already went out. Pair this with a 24-hour DynamoDB TTL so the table self-cleans.
### 6. Plain-text fallback, dark mode, and accessibility
Notification emails must include a plain-text body. HTML-only messages score worse with spam filters, screen readers fall back to text, and several regulated industries require text alternatives. React Email and MJML render plain-text variants automatically — always set `Body.Text.Data` alongside `Body.Html.Data`. While you are at it, test the HTML in Apple Mail dark mode and Outlook on Windows; those two clients break more notification templates than every other client combined.
### 7. Operational visibility per notification class
For each Configuration Set, alert on the four signals that actually predict deliverability problems:
- **Send-rate anomaly** — 10x drop or spike vs. the trailing 7-day average
- **Bounce rate by class** — billing emails bouncing at 3% indicates stale customer addresses
- **Complaint rate by class** — marketing complaints rising signals content or frequency issues
- **Per-domain delivery latency** — Outlook tar-pitting your password resets is an early reputation warning
Pipe these through the [Production Event Pipeline](#production-event-pipeline-ses--kinesis-firehose--s3--nodejs-api) described above so the same Athena queries answer "did the password reset arrive?" and "is our marketing reputation degrading?" with one data layer.
## Bounce and Complaint Handling
Postmark pushes bounce and complaint webhooks directly to an HTTP endpoint you provide. SES routes these through SNS, requiring a Lambda subscriber.
### Architecture:
```
SES sends email
↓
SES detects bounce or complaint
↓
SES publishes to SNS topic
↓
Lambda is subscribed to SNS topic
↓
Lambda processes event and calls your app (webhook)
```
### Mapping Postmark Webhook Fields to SES:
| Postmark Bounce Event | SES JSON | SES Bounce Type |
| -------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| `Type: "Permanent"` | `"bounce": { "bounceType": "Permanent" }` | Hard bounce — recipient address doesn't exist |
| `Type: "Transient"` | `"bounce": { "bounceType": "Transient" }` | Soft bounce — mailbox full, server temporarily unavailable |
| `Details: { Status: 422 }` | `"bounce": { "bounceSubType": "MailFromDomainNotVerified" }` | Bounce reason embedded in `bounceSubType` |
| Complaint webhook | `"complaint": { ... }` | Recipient marked your email as spam (IMPORTANT: add to suppression list immediately) |
### Lambda Handler Pattern:
```typescript
exports.handler = async (event) => {
// SNS wraps the SES event in a Message field
const message = JSON.parse(event.Records[0].Sns.Message);
if (message.bounce) {
console.log(`Hard bounce: ${message.bounce.bounceSubType}`);
// Update your suppression list
}
if (message.complaint) {
console.log(`Complaint from: ${message.complaint.complainedRecipients}`);
// Update your suppression list immediately
}
};
```
## Activity Log Replacement
This is the most painful part of the Postmark → SES transition. Postmark keeps 45 days of searchable email activity — every send, bounce, click, open, complaint — all searchable by recipient, subject, time range, etc.
**SES has no equivalent UI.** You get SNS events, but no built-in searchable history.
### DIY replacement architecture:
1. **SNS → Lambda** — capture all SES events (send, bounce, complaint, open, click)
2. **Lambda → DynamoDB** — store each event with TTL set to 45 days
3. **DynamoDB Streams → optional OpenSearch** — for full-text search on subjects and recipient emails
4. **Query layer** — Lambda API to search by email, date range, event type
**Estimated effort:** 20–30 hours to build; 1–2 hours/month to maintain.
**Cost impact:** DynamoDB for 45 days of email events:
- 500K emails/month = ~17K events/day = ~750K events in storage at any time
- DynamoDB on-demand: ~$0.25/month (read/write heavily discounted for low traffic)
- OpenSearch cluster: $50–200/month (optional, for advanced search)
**Bottom line:** This is a one-time engineering investment that pays back in 2–3 months versus Postmark's premium. For teams with engineering capacity, the ROI is clear.
## Production Event Pipeline: SES → Kinesis Firehose → S3 → Node.js API
The DynamoDB approach above works cleanly through about one million emails per month. Beyond that, the pattern most production SES customers settle on is streaming SES events into S3 via Kinesis Data Firehose, then querying through Athena or a lightweight Node.js API. This is also the architecture that gives you Postmark-grade analytics — opens, clicks, bounces, complaints, deliverability by domain — at a fraction of Postmark's cost.
```
SES Configuration Set
↓ (Event destination)
Kinesis Data Firehose
↓ (buffered batches: 60 s or 5 MB)
S3 (Parquet, partitioned by date / event_type)
↓
Node.js API ← Athena / DuckDB queries
↓
Dashboard, suppression service, or webhook fan-out
```
**Why this beats DynamoDB at scale:**
- **Cost** — S3 + Firehose for five million events per month runs around $5–8 versus $30–60 in DynamoDB on-demand
- **Queryability** — Athena lets analysts filter by recipient domain, subject line, click destination, or time window without provisioning a search cluster
- **Replay** — raw events in S3 are the source of truth; you can rebuild any downstream system by re-reading the bucket
- **Compliance** — S3 Object Lock plus lifecycle policies make seven-year retention trivial for regulated industries
**The Node.js API layer** is where most teams add real value. A small Express or Fastify service reads from Athena (or DuckDB if the working set fits in memory) and exposes Postmark-equivalent endpoints:
- `GET /messages?recipient=foo@bar.com` — Postmark-style activity log search
- `GET /deliverability?domain=gmail.com&days=7` — per-receiver placement metrics
- `GET /bounces?subType=MailboxFull&days=1` — operational alerting feed
- Webhook fan-out to Slack, PagerDuty, or your CRM on complaint events
**Practical tip — filter automated opens and prefetch clicks before the data hits your dashboard.** Apple Mail Privacy Protection, Gmail image proxies, and corporate security scanners (Microsoft Defender, Mimecast, Proofpoint) inflate raw open and click rates by 20–60%. Tools like [InboxEagle's Bot Finder](https://inboxeagle.com/bot-finder) plug into the Firehose stream or read from S3 to score each engagement event so your metrics reflect real humans rather than scanners. This becomes critical the moment you start using engagement-based suppression — segmenting on bot-inflated opens will gradually mail your inactive subscribers and erode sender reputation.
## IP Warming Schedule
Moving to dedicated IPs on SES requires warming to establish sender reputation. Follow this conservative schedule to ramp sending volume gradually:
| Day Range | Daily Volume Cap | Rationale |
| ---------- | ------------------- | ------------------------------------------------------------------- |
| Days 1–3 | 200 emails/day | Mailbox providers observe sender behavior; start conservatively |
| Days 4–6 | 500 emails/day | Gradual increase; mailbox providers are building reputation profile |
| Days 7–9 | 1,000 emails/day | Consistent sending establishes trust |
| Days 10–12 | 2,000 emails/day | Continue gradual ramp |
| Days 13–15 | 5,000 emails/day | Halfway through warming period |
| Days 16–18 | 10,000 emails/day | Reputation is building; can accelerate |
| Days 19–21 | 20,000 emails/day | Final ramping phase |
| Days 22–24 | 50,000 emails/day | Approaching full volume |
| Days 25–27 | 100,000 emails/day | Nearly at target |
| Days 28–30 | Full sending volume | Reputation established; send at full capacity |
**Critical metrics during warming:**
- **Bounce rate:** Keep below 2%. Hard bounces (permanent) are expected; transient bounces should drop over time.
- **Complaint rate:** Keep below 0.1%. One complaint per 1,000 emails is typical; higher signals list quality issues.
- **Seed list monitoring:** Send to role addresses (postmaster@, admin@) — these should never bounce or complain.
If bounce or complaint rates spike, **pause the ramp and investigate**. A spike at day 15 typically signals a bad list or authentication issue.
## List Hygiene, Cadence, and Engagement Filtering
IP warming establishes initial reputation. List hygiene and sending cadence preserve it. Teams that migrate to SES and lose deliverability six months later almost always made the same mistake — they assumed the warm-up was the hard part.
### Suppression beyond hard bounces
SES's account-level suppression list captures hard bounces and complaints automatically. That is the floor, not the ceiling. Build an application-layer suppression store that also handles:
- **Soft-bounce escalation** — three transient bounces in 30 days = suppress. Mailbox providers downgrade reputation when you keep retrying dead addresses.
- **Engagement decay** — recipients with zero opens or clicks in 90 days for marketing streams (180 days for transactional) move to a re-engagement segment or get suppressed.
- **Role addresses** — `info@`, `support@`, `sales@`, and `noreply@` belong on suppression for marketing streams. They generate complaints and rarely convert.
- **Disposable domains** — block known throwaway email providers at signup. These accounts inflate bounces and complaint rates.
### Cadence and throttling
Postmark's infrastructure throttles for you. SES will accept whatever volume your account quota allows, even when receivers start tar-pitting you.
- **Set a `MaxSendRate` ceiling** below your SES limit. Hitting `Throttling` errors is a leading indicator that you need to back off, not push harder.
- **Stagger sends across recipient domains.** Sending 50,000 emails to Gmail in one minute looks like a spam burst. Bucket sends by recipient domain and pace per-domain rates.
- **Avoid Monday 9 a.m. for everything.** When transactional and marketing streams fight for the same outbound capacity, transactional suffers. Schedule broadcasts away from peak transactional windows.
- **Use the SES `Reputation*` CloudWatch metrics to fail-safe.** If bounce rate crosses 5% or complaint rate 0.1%, SES auto-pauses the account. Alert on those thresholds before AWS does.
### Engagement-based inbox placement
Gmail, Outlook, and Yahoo all weight recent engagement heavily. Two senders with identical authentication and the same list will receive different placement based on the last 30 days of opens, replies, and "not spam" actions.
Three things to instrument:
1. **Per-domain engagement metrics** — open and reply rates broken out by gmail.com, yahoo.com, outlook.com, and corporate domains. A sudden Gmail-only drop usually signals reputation issues at Google specifically.
2. **Seed-list testing** — send to a panel of monitored inboxes (Gmail, Outlook, Yahoo, iCloud) on every major broadcast. Inbox vs. promotions vs. spam folder placement is your earliest warning system.
3. **Bot vs. human engagement** — separate genuine engagement from automated scanners before feeding it into segmentation logic. Engagement scoring tools that integrate with SES event streams make this practical at volume.
## Related Comparisons
Explore other technical comparisons:
- [SendGrid to AWS SES](/compare/sendgrid-to-aws-ses/)
- [Mailgun to AWS SES](/compare/mailgun-to-aws-ses/)
- [SparkPost to AWS SES](/compare/sparkpost-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)
---Postmark is the premium option in transactional email — priced accordingly, and genuinely worth it for teams that prioritize deliverability above all else. AWS SES is a commodity infrastructure service priced at cost. The decision to migrate is fundamentally about whether the cost savings justify building the operational scaffolding that Postmark handles for you.
This guide gives you the honest trade-off analysis and the step-by-step migration path if you decide to move.
The Cost Difference Is Stark
No other comparison in the transactional email space has this wide a price gap at volume.
| Volume | Postmark | AWS SES | Monthly Savings | Annual Savings |
|---|---|---|---|---|
| 10,000 emails | $15/month | $1.00/month | $14.00 | $168 |
| 50,000 emails | $50/month | $5.00/month | $45.00 | $540 |
| 125,000 emails | $100/month | $12.50/month | $87.50 | $1,050 |
| 500,000 emails | ~$400/month | $50.00/month | $350.00 | $4,200 |
| 1,000,000 emails | ~$800/month | $100.00/month | $700.00 | $8,400 |
SES dedicated IPs ($24.95/IP/month) add some cost, but even with two dedicated IPs, SES is 3–6x cheaper than Postmark at 500K+ emails per month.
Message Streams vs SES Configuration Sets
Postmark’s message streams are its most distinctive feature. Each stream has its own dedicated IP pool, separate bounce/complaint tracking, and isolated reputation. You create a transactional stream and a broadcast (marketing) stream, and Postmark ensures that a complaint spike on your broadcast stream cannot damage your transactional inbox placement.
SES does not have a native message stream concept, but you can replicate the isolation:
| Postmark Concept | SES Equivalent | Setup Required |
|---|---|---|
| Message stream (transactional) | Dedicated Configuration Set + IP pool | Create Config Set, request dedicated IPs, assign to transactional sends |
| Message stream (broadcast) | Separate Configuration Set + IP pool | Second Config Set with separate dedicated IPs; enforce separation in app code |
| Per-stream bounce tracking | Per-Configuration-Set SNS event destination | Create SNS topic per Config Set; subscribe Lambda to aggregate bounce metrics |
| Per-stream complaint tracking | Per-Configuration-Set SNS event destination | Same SNS topic or separate; Lambda processes complaint events per Config Set |
| Stream-level suppression | Account-level suppression list + custom store | SES account-level list handles hard bounces; application database for stream-level unsubscribes |
| Message stream isolation | IP pool isolation + Config Set enforcement | No automatic enforcement — application must select correct Config Set per send |
Authentication: SPF, DKIM, DMARC, and BIMI
Postmark configures most authentication for you behind the scenes — SES requires you to publish every record explicitly. Skipping this step is the single most common reason Postmark migrations see deliverability regress in the first 30 days. Get all four right before your first production send.
| Record | What it does | SES setup |
|---|---|---|
| SPF | Authorizes AWS SES IPs to send for your domain. Without it, Gmail and Outlook treat your mail as unauthenticated. | Add v=spf1 include:amazonses.com ~all to your sending domain’s TXT record. If you also send via Google Workspace or another provider, merge include: directives — never publish two SPF records. |
| DKIM | Cryptographically signs each message so receivers can verify it was not altered in transit. | Verify the domain in SES → enable Easy DKIM → publish the three CNAME records SES provides. Rotate keys yearly using BYODKIM if you operate under regulatory scope (HIPAA, PCI, SOC 2). |
| DMARC | Tells receivers what to do when SPF or DKIM fail and gives you forensic visibility into spoofing attempts. | Start at p=none with an rua= aggregate-report mailbox, monitor for two to four weeks, then move to p=quarantine and finally p=reject. Gmail and Yahoo’s 2024 sender requirements made enforcement mandatory for bulk senders. |
| BIMI | Displays your verified logo next to messages in supporting inboxes (Gmail, Apple Mail, Yahoo). Lifts open rates 5–15%. | Requires p=quarantine or p=reject DMARC enforcement plus a Verified Mark Certificate (VMC) from Entrust or DigiCert. Publish the SVG-Tiny logo at the URL referenced in your BIMI TXT record. |
Migration order matters. Publish SPF and DKIM the moment you verify your SES domain, monitor DMARC aggregate reports for two weeks before enforcement, then layer BIMI once enforcement is stable. Flipping straight to p=reject on day one will silently drop legitimate fallback traffic if anything is misconfigured.
API Migration — Postmark → AWS SES
The biggest operational change is moving from Postmark’s HTTP-based API to SES’s SDK calls. Here’s the mapping:
| Postmark | AWS SES | Migration Notes |
|---|---|---|
POST /email | SendEmail() or SendTemplatedEmail() | Postmark accepts JSON; SES uses SDK methods. Use boto3 or AWS SDK for your language. |
MessageStream: "transactional" | ConfigurationSetName: "transactional" | Set this on every SendEmail() call to route to the right IP pool |
Template system (Mustache {{var}}) | No template engine | Move rendering to application layer. Use React Email, MJML, or Handlebars in your app. Pre-render the full HTML, send via SES. |
Metadata: { key: value } | MessageAttributes | SES equivalent for custom headers; same pattern for tracking. |
| Webhook pushes to HTTP endpoint | SNS topic subscriptions | SES events → SNS → Lambda → your application. More infrastructure but more control. |
Key difference: Postmark templates live in Postmark’s UI. SES has no template engine. This forces you to move template logic to your application layer, which is actually cleaner long-term (you control versioning, you can test templates with your code).
Sending Notification Emails on SES (the Postmark Replacement Pattern)
Notification emails — password resets, account verifications, MFA codes, receipts, payment-failed alerts, comment replies, security warnings — are Postmark’s signature use case. Replicating that workflow on SES is straightforward once four patterns are in place: per-category Configuration Sets, event-driven triggering, application-layer template rendering, and idempotent at-least-once delivery.
1. Notification taxonomy and Configuration Sets
Postmark gives you message streams; SES gives you Configuration Sets. Treat each notification class as its own Configuration Set so analytics, suppression policy, and IP routing stay isolated.
| Notification class | Examples | SES Configuration Set | IP pool |
|---|---|---|---|
| Account critical | Password reset, email verification, MFA codes, login alerts | notif-critical | Dedicated, transactional-only |
| Billing & receipts | Invoices, receipts, payment failed, subscription renewal | notif-billing | Dedicated, transactional-only |
| Product activity | Comment replies, mentions, status changes, exports ready | notif-activity | Shared transactional pool |
| Marketing & lifecycle | Newsletters, product announcements, re-engagement | notif-marketing | Separate marketing IP pool |
Setting ConfigurationSetName on every SendEmail call enforces the separation. A bounce on a marketing send never touches password-reset deliverability — provided the right Configuration Set is selected per send.
2. Triggering — event-driven, not synchronous
Calling SES directly from a request handler works for one-off mail but breaks under load and gives no retry semantics. Wire notifications through an event bus instead.
Application
↓ publishes domain event
EventBridge / SNS
↓ filters and routes
SQS (with DLQ)
↓
Lambda (notification-sender)
↓ suppression check → render template → call SES
AWS SES → recipientWhat this buys you:
- Retries are free. Lambda + SQS DLQ retries failed sends without your application code knowing.
- Decoupling. Adding a “weekly digest” notification never touches the request path.
- Observability. Every notification flows through one Lambda, one CloudWatch log group, one X-Ray trace.
- Replay. EventBridge archives let you re-run the last 24 hours of notifications in staging without re-running business logic.
3. Template rendering at the application layer
Postmark templates ship with the platform; SES templates lack conditionals and inheritance. Render in your code using React Email, MJML, or Handlebars, then send the pre-rendered HTML and plain-text bodies through SendEmail.
import { render } from '@react-email/render';
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import PasswordResetEmail from './emails/password-reset';
const ses = new SESClient({ region: 'us-east-1' });
export async function sendPasswordReset(user, resetUrl) {
const html = render(PasswordResetEmail({ name: user.name, resetUrl }));
const text = render(PasswordResetEmail({ name: user.name, resetUrl }), {
plainText: true,
});
await ses.send(
new SendEmailCommand({
Source: '"Acme Security" <security@acme.com>',
Destination: { ToAddresses: [user.email] },
Message: {
Subject: { Data: 'Reset your password' },
Body: {
Html: { Data: html },
Text: { Data: text },
},
},
ConfigurationSetName: 'notif-critical',
Tags: [
{ Name: 'notification_type', Value: 'password_reset' },
{ Name: 'user_id', Value: user.id },
],
})
);
}Three wins over Postmark-hosted templates:
- Version control — templates live next to the code that triggers them.
- Type safety — TypeScript catches missing template variables at build time.
- Local preview — render in Storybook or a Node script without sending real mail.
4. Suppression and preference check before every send
Postmark blocks sends to suppressed addresses automatically. SES blocks at the account level for hard bounces and complaints, but you still need to check per-notification preferences before calling SendEmail. A defensive sender Lambda runs four checks in order:
- Account-level SES suppression — cached from the
GetSuppressedDestinationAPI - Application unsubscribe table — has the user opted out of this category?
- Frequency cap — have we already sent this exact notification in the last N hours?
- Quiet hours — does the user’s timezone preference block sends right now?
Checks fail closed. Anything ambiguous defaults to “do not send.” An unsent notification is recoverable; an unwanted notification is not.
5. Idempotency for at-least-once delivery
EventBridge and SQS both deliver at-least-once. Without an idempotency key, a single password-reset event can produce three emails when retries fire. Derive an idempotency key from {event_id}:{notification_type}:{user_id} and write it to DynamoDB with a conditional attribute_not_exists:
await ddb.send(
new PutItemCommand({
TableName: 'notification-idempotency',
Item: {
idempotency_key: { S: `${eventId}:password_reset:${userId}` },
expires_at: { N: String(Math.floor(Date.now() / 1000) + 86400) },
},
ConditionExpression: 'attribute_not_exists(idempotency_key)',
})
);If the conditional check fails, log and exit — the email already went out. Pair this with a 24-hour DynamoDB TTL so the table self-cleans.
6. Plain-text fallback, dark mode, and accessibility
Notification emails must include a plain-text body. HTML-only messages score worse with spam filters, screen readers fall back to text, and several regulated industries require text alternatives. React Email and MJML render plain-text variants automatically — always set Body.Text.Data alongside Body.Html.Data. While you are at it, test the HTML in Apple Mail dark mode and Outlook on Windows; those two clients break more notification templates than every other client combined.
7. Operational visibility per notification class
For each Configuration Set, alert on the four signals that actually predict deliverability problems:
- Send-rate anomaly — 10x drop or spike vs. the trailing 7-day average
- Bounce rate by class — billing emails bouncing at 3% indicates stale customer addresses
- Complaint rate by class — marketing complaints rising signals content or frequency issues
- Per-domain delivery latency — Outlook tar-pitting your password resets is an early reputation warning
Pipe these through the Production Event Pipeline described above so the same Athena queries answer “did the password reset arrive?” and “is our marketing reputation degrading?” with one data layer.
Bounce and Complaint Handling
Postmark pushes bounce and complaint webhooks directly to an HTTP endpoint you provide. SES routes these through SNS, requiring a Lambda subscriber.
Architecture:
SES sends email
↓
SES detects bounce or complaint
↓
SES publishes to SNS topic
↓
Lambda is subscribed to SNS topic
↓
Lambda processes event and calls your app (webhook)Mapping Postmark Webhook Fields to SES:
| Postmark Bounce Event | SES JSON | SES Bounce Type |
|---|---|---|
Type: "Permanent" | "bounce": { "bounceType": "Permanent" } | Hard bounce — recipient address doesn’t exist |
Type: "Transient" | "bounce": { "bounceType": "Transient" } | Soft bounce — mailbox full, server temporarily unavailable |
Details: { Status: 422 } | "bounce": { "bounceSubType": "MailFromDomainNotVerified" } | Bounce reason embedded in bounceSubType |
| Complaint webhook | "complaint": { ... } | Recipient marked your email as spam (IMPORTANT: add to suppression list immediately) |
Lambda Handler Pattern:
exports.handler = async (event) => {
// SNS wraps the SES event in a Message field
const message = JSON.parse(event.Records[0].Sns.Message);
if (message.bounce) {
console.log(`Hard bounce: ${message.bounce.bounceSubType}`);
// Update your suppression list
}
if (message.complaint) {
console.log(`Complaint from: ${message.complaint.complainedRecipients}`);
// Update your suppression list immediately
}
};Activity Log Replacement
This is the most painful part of the Postmark → SES transition. Postmark keeps 45 days of searchable email activity — every send, bounce, click, open, complaint — all searchable by recipient, subject, time range, etc.
SES has no equivalent UI. You get SNS events, but no built-in searchable history.
DIY replacement architecture:
- SNS → Lambda — capture all SES events (send, bounce, complaint, open, click)
- Lambda → DynamoDB — store each event with TTL set to 45 days
- DynamoDB Streams → optional OpenSearch — for full-text search on subjects and recipient emails
- Query layer — Lambda API to search by email, date range, event type
Estimated effort: 20–30 hours to build; 1–2 hours/month to maintain.
Cost impact: DynamoDB for 45 days of email events:
- 500K emails/month = ~17K events/day = ~750K events in storage at any time
- DynamoDB on-demand: ~$0.25/month (read/write heavily discounted for low traffic)
- OpenSearch cluster: $50–200/month (optional, for advanced search)
Bottom line: This is a one-time engineering investment that pays back in 2–3 months versus Postmark’s premium. For teams with engineering capacity, the ROI is clear.
Production Event Pipeline: SES → Kinesis Firehose → S3 → Node.js API
The DynamoDB approach above works cleanly through about one million emails per month. Beyond that, the pattern most production SES customers settle on is streaming SES events into S3 via Kinesis Data Firehose, then querying through Athena or a lightweight Node.js API. This is also the architecture that gives you Postmark-grade analytics — opens, clicks, bounces, complaints, deliverability by domain — at a fraction of Postmark’s cost.
SES Configuration Set
↓ (Event destination)
Kinesis Data Firehose
↓ (buffered batches: 60 s or 5 MB)
S3 (Parquet, partitioned by date / event_type)
↓
Node.js API ← Athena / DuckDB queries
↓
Dashboard, suppression service, or webhook fan-outWhy this beats DynamoDB at scale:
- Cost — S3 + Firehose for five million events per month runs around $5–8 versus $30–60 in DynamoDB on-demand
- Queryability — Athena lets analysts filter by recipient domain, subject line, click destination, or time window without provisioning a search cluster
- Replay — raw events in S3 are the source of truth; you can rebuild any downstream system by re-reading the bucket
- Compliance — S3 Object Lock plus lifecycle policies make seven-year retention trivial for regulated industries
The Node.js API layer is where most teams add real value. A small Express or Fastify service reads from Athena (or DuckDB if the working set fits in memory) and exposes Postmark-equivalent endpoints:
GET /messages?recipient=foo@bar.com— Postmark-style activity log searchGET /deliverability?domain=gmail.com&days=7— per-receiver placement metricsGET /bounces?subType=MailboxFull&days=1— operational alerting feed- Webhook fan-out to Slack, PagerDuty, or your CRM on complaint events
Practical tip — filter automated opens and prefetch clicks before the data hits your dashboard. Apple Mail Privacy Protection, Gmail image proxies, and corporate security scanners (Microsoft Defender, Mimecast, Proofpoint) inflate raw open and click rates by 20–60%. Tools like InboxEagle’s Bot Finder plug into the Firehose stream or read from S3 to score each engagement event so your metrics reflect real humans rather than scanners. This becomes critical the moment you start using engagement-based suppression — segmenting on bot-inflated opens will gradually mail your inactive subscribers and erode sender reputation.
IP Warming Schedule
Moving to dedicated IPs on SES requires warming to establish sender reputation. Follow this conservative schedule to ramp sending volume gradually:
| Day Range | Daily Volume Cap | Rationale |
|---|---|---|
| Days 1–3 | 200 emails/day | Mailbox providers observe sender behavior; start conservatively |
| Days 4–6 | 500 emails/day | Gradual increase; mailbox providers are building reputation profile |
| Days 7–9 | 1,000 emails/day | Consistent sending establishes trust |
| Days 10–12 | 2,000 emails/day | Continue gradual ramp |
| Days 13–15 | 5,000 emails/day | Halfway through warming period |
| Days 16–18 | 10,000 emails/day | Reputation is building; can accelerate |
| Days 19–21 | 20,000 emails/day | Final ramping phase |
| Days 22–24 | 50,000 emails/day | Approaching full volume |
| Days 25–27 | 100,000 emails/day | Nearly at target |
| Days 28–30 | Full sending volume | Reputation established; send at full capacity |
Critical metrics during warming:
- Bounce rate: Keep below 2%. Hard bounces (permanent) are expected; transient bounces should drop over time.
- Complaint rate: Keep below 0.1%. One complaint per 1,000 emails is typical; higher signals list quality issues.
- Seed list monitoring: Send to role addresses (postmaster@, admin@) — these should never bounce or complain.
If bounce or complaint rates spike, pause the ramp and investigate. A spike at day 15 typically signals a bad list or authentication issue.
List Hygiene, Cadence, and Engagement Filtering
IP warming establishes initial reputation. List hygiene and sending cadence preserve it. Teams that migrate to SES and lose deliverability six months later almost always made the same mistake — they assumed the warm-up was the hard part.
Suppression beyond hard bounces
SES’s account-level suppression list captures hard bounces and complaints automatically. That is the floor, not the ceiling. Build an application-layer suppression store that also handles:
- Soft-bounce escalation — three transient bounces in 30 days = suppress. Mailbox providers downgrade reputation when you keep retrying dead addresses.
- Engagement decay — recipients with zero opens or clicks in 90 days for marketing streams (180 days for transactional) move to a re-engagement segment or get suppressed.
- Role addresses —
info@,support@,sales@, andnoreply@belong on suppression for marketing streams. They generate complaints and rarely convert. - Disposable domains — block known throwaway email providers at signup. These accounts inflate bounces and complaint rates.
Cadence and throttling
Postmark’s infrastructure throttles for you. SES will accept whatever volume your account quota allows, even when receivers start tar-pitting you.
- Set a
MaxSendRateceiling below your SES limit. HittingThrottlingerrors is a leading indicator that you need to back off, not push harder. - Stagger sends across recipient domains. Sending 50,000 emails to Gmail in one minute looks like a spam burst. Bucket sends by recipient domain and pace per-domain rates.
- Avoid Monday 9 a.m. for everything. When transactional and marketing streams fight for the same outbound capacity, transactional suffers. Schedule broadcasts away from peak transactional windows.
- Use the SES
Reputation*CloudWatch metrics to fail-safe. If bounce rate crosses 5% or complaint rate 0.1%, SES auto-pauses the account. Alert on those thresholds before AWS does.
Engagement-based inbox placement
Gmail, Outlook, and Yahoo all weight recent engagement heavily. Two senders with identical authentication and the same list will receive different placement based on the last 30 days of opens, replies, and “not spam” actions.
Three things to instrument:
- Per-domain engagement metrics — open and reply rates broken out by gmail.com, yahoo.com, outlook.com, and corporate domains. A sudden Gmail-only drop usually signals reputation issues at Google specifically.
- Seed-list testing — send to a panel of monitored inboxes (Gmail, Outlook, Yahoo, iCloud) on every major broadcast. Inbox vs. promotions vs. spam folder placement is your earliest warning system.
- Bot vs. human engagement — separate genuine engagement from automated scanners before feeding it into segmentation logic. Engagement scoring tools that integrate with SES event streams make this practical at volume.
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
Is AWS SES as reliable as Postmark?
How do I migrate Postmark templates to AWS SES?
Does AWS SES have message streams like Postmark?
Is Postmark worth the premium over SES?
What does Postmark charge vs AWS SES?
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.
