Switch from Amazon SES to Anypost
SES requires AWS credentials, region-specific endpoints, deeply nested JSON, and SNS for webhooks. Anypost replaces all of that with a single API key and a flat REST API.
What's the same, what changed
SES is AWS-native: you authenticate with IAM, call region-specific endpoints, and route events through SNS. Anypost is a standalone email API, with one key, one URL, and direct HTTP webhooks.
| Concept | Amazon SES | Anypost |
|---|---|---|
| Auth | AWS Signature v4 (AWS SDK / IAM) | Authorization: Bearer ap_… |
| Credentials | Access key ID + secret access key | A single ap_… API key |
| Base URL | email.{region}.amazonaws.com | api.anypost.com/v1 (global) |
| Send endpoint | POST /v2/email/outbound-emails | POST /v1/email |
| From address | FromEmailAddress (top-level) | from (top-level) |
| To addresses | Destination.ToAddresses: [...] | to: [...] |
| Subject | Content.Simple.Subject.Data | subject (top-level) |
| HTML body | Content.Simple.Body.Html.Data | html (top-level) |
| Text body | Content.Simple.Body.Text.Data | text (top-level) |
| Tags | EmailTags: [{Name, Value}] | tags: ["string"] |
| Response | {MessageId: "…"} | 202 Accepted, {id, created_at} |
| Webhooks | Amazon SNS to HTTP subscription | Direct HTTP POST to your URL |
| Open / click tracking | Event Publishing to Kinesis / CloudWatch | Built-in webhook events |
| SMTP host | email-smtp.{region}.amazonaws.com | smtp.anypost.com (global) |
| SMTP username | IAM-generated SMTP username | anypost |
| SMTP password | Derived from your AWS secret key | Your ap_… API key |
| Before production | Sandbox, then a production-access request | Evaluation, then onboarding with our team |
The biggest friction with SES is setup overhead: IAM permissions, region selection, SNS topic wiring, and the AWS SDK dependency. On Anypost you verify your domain, create one API key, and build against the real API, with no AWS account required.
Seven steps to switch
SES can stay active while you test Anypost; the two sets of DNS records don't conflict, so verify delivery one email type at a time before cutting over completely.
Create an Anypost account and verify your domain
Sign up at anypost.com and add your sending domain (the same one you use with SES). You'll publish three CNAME records (one for verification, two for DKIM), then click verify; Anypost hosts the SPF and DKIM records they point to, and on Cloudflare one click creates them for you. During your evaluation, your account sends to your own verified domains, so you can build and test everything first. See Domains.
Create an API key
In the Anypost dashboard, create a key (it starts with ap_). This one
key replaces your entire AWS IAM credential setup: no access keys, secret
keys, regions, or IAM policies to manage. Set it as ANYPOST_API_KEY.
See Authentication.
Replace the AWS SDK with the Anypost SDK
Uninstall @aws-sdk/client-sesv2 (or boto3, or the relevant AWS SDK)
and install anypost. The Anypost request body is flat, so you'll delete
a lot of nesting.
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
const client = new SESv2Client({ region: "us-east-1" });
await client.send(new SendEmailCommand({
FromEmailAddress: "[email protected]",
Destination: { ToAddresses: ["[email protected]"] },
Content: {
Simple: {
Subject: { Data: "Welcome", Charset: "UTF-8" },
Body: {
Html: { Data: "<p>Hi.</p>", Charset: "UTF-8" },
},
},
},
}));import { Anypost } from "anypost";
const client = new Anypost("ap_…");
await client.email.send({
from: "[email protected]",
to: ["[email protected]"],
subject: "Welcome",
html: "<p>Hi.</p>",
});Replace SNS notifications with direct webhooks
SES delivers events through Amazon SNS, which wraps the real notification in an outer envelope. Anypost posts directly to your HTTPS endpoint with a plain JSON body, with no SNS topics, subscriptions, or confirmation handshakes. See the Webhooks section below.
Update your SMTP config (if applicable)
SES SMTP credentials are IAM-generated and region-specific. Anypost uses
a single global endpoint with your ap_ key as the password. If you run
SES in multiple regions, you can consolidate to one Anypost config. See
Sending over SMTP.
Bring your suppression list
Export SES's account-level suppression list (ListSuppressedDestinations) and add each address with POST /v1/suppressions, so your
first Anypost sends skip addresses that have already hard-bounced,
complained, or opted out. Give each unsubscribe the topic it opted out
of, such as marketing, so receipts and password resets still reach
that person. See Suppressions.
for (const { email, topic } of exported) {
await anypost.suppressions.create({ email, topic });
}Move your volume over with us
Our deliverability experts plan your warm-up from what you told us about your sending. Move one email type at a time, starting with the mail your recipients expect most, while we raise your volume at a pace mailbox providers accept, so they see a steady sender, not a sudden new one. Questions about timing? Talk to us.
The same change in every SDK
The pattern is the same everywhere: remove the AWS SDK, remove the nested content wrappers, and call the Anypost SDK with a flat body. Pick your language.
# SES requires AWS Signature v4, which is not practical
# to sign by hand. Use the AWS CLI instead:
aws sesv2 send-email \
--region us-east-1 \
--from-email-address [email protected] \
--destination [email protected] \
--content 'Simple={
Subject={Data=Welcome,Charset=UTF-8},
Body={Html={Data=<p>Hi.</p>,Charset=UTF-8}}
}'curl https://api.anypost.com/v1/email \
-H "Authorization: Bearer ap_…" \
-H "Content-Type: application/json" \
-d '{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome",
"html": "<p>Hi.</p>"
}'No request signing. SES requires AWS Signature v4, so a raw
curl call needs Authorization,
X-Amz-Date, and related headers computed from your credentials.
Anypost's Bearer token works with any HTTP client, no signing step.
Webhooks
SES doesn't post directly to your server. It publishes to an Amazon SNS
topic, which delivers an outer envelope whose Message field is a
JSON-encoded string you have to parse a second time. Anypost posts its events
directly to your URL in a batched events array.
| SES notificationType | Anypost event | Notes | |
|---|---|---|---|
Delivery | email.delivered | rename | Accepted by the recipient's mail server |
Bounce (Permanent) | email.bounced | rename | bounce.type: "permanent"; Anypost suppresses the address |
Bounce (Transient) | email.bounced | rename | bounce.type: "transient", after the final retry |
Complaint | email.complained | rename | Feedback-loop spam report |
| Open (Event Publishing) | email.opened | rename | No Kinesis/CloudWatch; a direct webhook |
| Click (Event Publishing) | email.clicked | rename | No Kinesis/CloudWatch; a direct webhook |
Anypost also emits email.delayed while a delivery is failing
temporarily and still being retried, which SES surfaces only as a terminal
Transient bounce. You don't need to handle it to migrate.
{
"Type": "Notification",
"MessageId": "uuid",
"Message": "{\"notificationType\":\"Bounce\",\"mail\":{\"messageId\":\"…\",\"destination\":[\"[email protected]\"]},\"bounce\":{\"bounceType\":\"Permanent\",\"bouncedRecipients\":[{\"emailAddress\":\"[email protected]\"}]}}"
}{
"batch_id": "9c1f2a7b…",
"events": [
{
"type": "email.bounced",
"occurred_at": "2026-01-15T10:30:00Z",
"data": {
"email_id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d",
"recipient": "[email protected]"
}
}
]
}// SES via SNS: unwrap the envelope, parse the inner JSON string
app.post("/webhook/email", async (req, res) => {
const sns = req.body;
if (sns.Type === "SubscriptionConfirmation") {
await fetch(sns.SubscribeURL); // confirm the SNS subscription
return res.sendStatus(200);
}
const event = JSON.parse(sns.Message); // double-encoded
switch (event.notificationType) {
case "Bounce":
suppressAddress(event.bounce.bouncedRecipients[0].emailAddress);
break;
case "Complaint":
unsubscribe(event.complaint.complainedRecipients[0].emailAddress);
break;
case "Delivery":
markDelivered(event.mail.messageId);
break;
}
res.sendStatus(200);
});// Anypost: a batched events array, standard names, no unwrapping
app.post("/webhook/email", (req, res) => {
for (const { type, data } of req.body.events) {
switch (type) {
case "email.delivered": markDelivered(data.email_id); break;
case "email.bounced":
if (data.bounce.type === "permanent") suppressAddress(data.recipient);
break;
case "email.complained": unsubscribe(data.recipient); break;
case "email.opened":
case "email.clicked": recordEngagement(data); break;
}
}
res.sendStatus(200);
});Suppress on permanent bounces only.
email.bounced also reports temporary failures and messages that
expired after 72 hours of retries. Anypost already suppresses permanent
bounces for you, so if you mirror the list, check
data.bounce.type first. See Webhooks.
Signature verification differs. SES messages are signed by
SNS (an x509 certificate you fetch and verify). Anypost signs with an
Anypost-Signature header (t=<unix>,v1=<hmac>)
over the timestamp and raw body. You can drop the SNS subscription-confirmation
handler entirely. See Webhooks.
Register your HTTPS URL once in the Anypost dashboard and select which events to receive. No SNS topics, subscriptions, or confirmation handshake.
Using SMTP instead of the API?
SES SMTP credentials are IAM-generated and region-specific; the SMTP password
is derived from your AWS secret key by a special algorithm, not the secret
itself. Anypost uses your ap_ key directly as the password.
# Region-specific host, different per AWS region
SMTP_HOST=email-smtp.us-east-1.amazonaws.com
SMTP_PORT=587
# IAM-generated SMTP username (not your AWS access key)
SMTP_USER=AKIAIOSFODNN7EXAMPLE
# Derived SMTP password (not your AWS secret key)
SMTP_PASS=BXXhFg5Xb9/0sJtqo...
SMTP_STARTTLS=true# One global host, no region configuration
SMTP_HOST=smtp.anypost.com
SMTP_PORT=587
SMTP_USER=anypost
# Your API key is the password, no derivation step
SMTP_PASS=ap_…
SMTP_STARTTLS=trueIf you run SES across multiple regions, each has its own SMTP hostname and
its own IAM-generated credentials. Anypost uses one global endpoint wherever
your servers run. Supported ports are 587 (recommended),
2587, and 25; STARTTLS is required on every port,
and there's no implicit-TLS port, so move off 465 to
587. See Sending over SMTP.
Ready to switch?
Start with a free evaluation and build against the real API, then bring your volume over while our deliverability experts warm up your sending.