From Amazon SES to Postal, with SES underneath
You already send through SES. On Postal, delivery is still handled by SES, and what used to be on you comes ready-made: guided domain setup, events as webhooks, the suppression list and care for your reputation.
- For DNS to be checked
- Up to 72 h
- Both work
- API or SMTP
- Events already as webhooks
- No SNS
- The sending region
- Per domain
Overview
What changes when SES sits underneath
Postal sends through its own Amazon SES account, not yours. That changes who looks after what.
Today, directly on SES
- Your systemYou send with the AWS SDK or through SES SMTP.
- You configure and watchIdentity and DKIM, configuration set, SNS topic, suppression list, quotas and leaving the sandbox.
- Amazon SESIn your AWS account.
With Postal
- Your systemYou send through the Postal API or SMTP.
- Postal handles itGuided domain setup, events as webhooks, per-team suppression and reputation, with a warning before pausing.
- Amazon SESIn Postal's account, in the region you choose for the domain.
What Postal takes over
Domain verification, with diagnostics in Portuguese, signed webhook events, the team's suppression list (permanent bounces and complaints are added automatically) and the warning before sending is paused over reputation.
See deliverabilityWhat is still SES
Delivery itself. Emails leave through Amazon SES infrastructure, which is why bounces have the SES format and the Return-Path points to feedback-smtp.<região>.amazonses.com.
What changes in your AWS account
Your account's quota and sandbox stop applying to Postal. The plan applies: daily and monthly quota, request limit and pause over reputation. Your account can stay as it is until you turn off the last send.
See limitsPostal's documentation does not cover dedicated SES IPs. If your account uses dedicated IPs, talk to us through the contact form before migrating.
Take inventory in SES
Before changing anything, list what your system uses in SES. Each item has a place on Postal. SES identities are valid for one region only, so note the regions you send from.
| In the SES console | What to note | Where it lives on Postal |
|---|---|---|
| Identities | Verified domains and addresses, and the region of each | Domains (the region is chosen per domain) |
| Configuration sets | Event names and destinations | Webhooks, and open and click tracking on the domain |
| Email templates | Template name and variables | Templates |
| SNS topics | Who subscribes and what code receives the events | Webhooks |
| Suppression list | Suppressed addresses, by region | Suppressions |
| SMTP settings | Who uses the SMTP credentials | API keys |
Verify the domain on Postal
Add the domain under Domains (or with POST /domains), choose the region and publish the records Postal shows. It checks DNS on its own for up to 72 hours, shows the status of each record and explains what is wrong. The domain can stay verified in SES at the same time: the record names are different.
| On SES | On Postal | |
|---|---|---|
| DKIM | 3 CNAME records, <token>._domainkey, pointing to <token>.dkim.<região>.amazonses.com (Easy DKIM) | 1 TXT record, postal._domainkey, with Postal's public key |
| Return-Path (MAIL FROM) | Optional. A subdomain of yours, with MX 10 feedback-smtp.<região>.amazonses.com and TXT "v=spf1 include:amazonses.com ~all" | The postal subdomain (changes with custom_return_path): MX to feedback-smtp.<região>.amazonses.com and TXT v=spf1 include:_spf.postal.so ~all |
| SPF of the main domain | Yours, if you included amazonses.com | Does not change: Postal's SPF lives on the Return-Path subdomain |
| DMARC | Yours | Still applies. Postal does not require it, but the big providers expect it from anyone sending volume |
| Region | One identity per region | One region per domain: São Paulo (default), Northern Virginia, Ohio, Ireland or Tokyo |
| Deadline | Up to 72 hours for DNS to propagate | Up to 72 hours, with diagnostics in Portuguese for each record |
- If your Return-Path in SES is already called
postal, pick another name on Postal withcustom_return_path, so the records do not get mixed up. - To migrate gradually, use an email-only subdomain, such as
mail.exemplo.com.br. - Postal notifies you through the
domain.updatedwebhook when the domain becomesverified. See Domains and DNS.
Replace the SDK call with the API
Create an API key (full access, or send-only and restricted to the domain) and replace the AWS SDK's SendEmail with POST /emails. The response carries the email's id, in place of the MessageId.
- import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'- - const ses = new SESv2Client({ region: 'us-east-1' })- await ses.send(new SendEmailCommand({- FromEmailAddress: 'Aurora Cosméticos <[email protected]>',- Destination: { ToAddresses: ['[email protected]'] },- Content: { Simple: {- Subject: { Data: 'Your order is out for delivery' },- Body: { Html: { Data: '<p>Order #10482 is on its way.</p>' } },- } },- EmailTags: [{ Name: 'category', Value: 'shipping' }],- }))+ await fetch('https://api.postal.so/emails', {+ method: 'POST',+ headers: {+ Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,+ 'Content-Type': 'application/json',+ },+ body: JSON.stringify({+ from: 'Aurora Cosméticos <[email protected]>',+ to: ['[email protected]'],+ subject: 'Your order is out for delivery',+ html: '<p>Order #10482 is on its way.</p>',+ tags: [{ name: 'category', value: 'shipping' }],+ }),+ })| SES field (SendEmail, v2) | On Postal | What changes |
|---|---|---|
FromEmailAddress | from | The domain must be verified on Postal. |
Destination.ToAddresses | to | Text or list, with up to 50 recipients. |
CcAddresses, BccAddresses | cc, bcc | Iguais. |
ReplyToAddresses | reply_to | Text or list. |
Content.Simple (Subject, Body.Html, Body.Text) | subject, html, text | No Data and Charset objects: it is the text directly. |
Content.Simple.Headers | headers | A name → value object. Some names cannot be overridden (From, To, Subject...). |
Content.Simple.Attachments | attachments | filename and content in base64, or path with a URL. Up to 40 MB in total. The content_id is for inline images. |
Content.Template (TemplateName, TemplateData) | template (id or alias, variables) | Variables go from {{nome}} to {{{NOME}}}. Text and numbers only: lists and nested objects must be resolved before sending. |
Content.Raw | No field in the API | Use Postal SMTP, which delivers the message as it arrived. |
EmailTags (Name, Value) | tags (name, value) | ASCII letters, numbers, _ and -. Up to 50 per email. |
ConfigurationSetName | Not needed | Events go to webhooks. |
ConfigurationOverrides.Tracking | open_tracking, click_tracking | They are enabled on the domain, not on each message. |
FeedbackForwardingEmailAddress | Not needed | Bounces and complaints become events and go to suppression. The Return-Path belongs to Postal. |
SendBulkEmail | POST /emails/batch | A list of up to 100 emails (on SES, up to 50 destinations). No attachments or scheduled_at, and it depends on the plan. |
| Doesn't exist | scheduled_at, Idempotency-Key | New: schedule up to 30 days ahead (depends on the plan) and repeat a call without duplicating the send, for 24 hours. |
- Errors arrive with the HTTP status and a JSON
{statusCode, name, message}. The quota answers429and the pause over reputation answers403 sending_paused. - Prefer an SDK? Postal's (Node.js, PHP and Laravel) and Resend's, pointed at Postal, use the same API. See SDKs and CLI and the emails reference.
If you send over SMTP, change only the server
Systems that speak SMTP, such as WordPress, ERPs and store platforms, change four fields: server, port, username and password. The password stops being the SMTP password derived from an IAM key and becomes a Postal API key.
- MAIL_HOST=email-smtp.us-east-1.amazonaws.com- MAIL_PORT=587- MAIL_USERNAME=<SES SMTP user>- MAIL_PASSWORD=<SES SMTP password>+ MAIL_HOST=smtp.postal.so+ MAIL_PORT=587+ MAIL_USERNAME=postal+ MAIL_PASSWORD=ps_your_key| SES SMTP | Postal SMTP | |
|---|---|---|
| Server | email-smtp.<região>.amazonaws.com | smtp.postal.so |
| STARTTLS ports | 25, 587 or 2587 | 587 or 2587 |
| Direct TLS ports | 465 or 2465 | 465 (recommended) or 2465 |
| Username | The SMTP user generated in SES, one pair per region | postal |
| Password | The SMTP password, which is not the AWS secret key | An API key (ps_…). A send-only key is enough |
| Sender | A verified identity in SES | A verified domain on Postal |
- TLS is required on both. On Postal, authentication (
AUTH PLAINorAUTH LOGIN) always comes after TLS. - Postal SMTP has no tags, scheduling or templates: for those, use the API. If your system sends the
X-SES-CONFIGURATION-SETorX-SES-MESSAGE-TAGSheaders, remove them, because they belong to SES. - SMTP sends follow the same rules as the API (verified domain, quotas and suppression) and show up in Emails and Logs. Each accepted message can be up to 40 MB with 50 recipients. See the SMTP guide.
Replace SNS with webhooks
On SES, the event path is yours to build: configuration set, event destination, SNS topic and a subscription your code has to receive. On Postal you register an HTTPS URL under Webhooks and pick the events. Each delivery is signed with the endpoint's signing_secret and retried if it fails.
- const message = JSON.parse(body.Message)- if (message.eventType === 'Bounce') {- const kind = message.bounce.bounceType- const id = message.mail.messageId- }+ const event = resend.webhooks.verify({ payload: rawBody, headers, webhookSecret })+ if (event.type === 'email.bounced') {+ const kind = event.data.bounce.type+ const id = event.data.email_id+ }| SES event (eventType) | Postal event | Note |
|---|---|---|
Send | email.sent | Postal handed the email to the sending provider. |
Delivery | email.delivered | The recipient's server accepted the message. |
DeliveryDelay | email.delivery_delayed | Retries happen automatically. |
Bounce | email.bounced | data.bounce carries type (Permanent, Transient or Undetermined), subType and diagnosticCode. A permanent bounce puts the address on suppression. |
Complaint | email.complained | The address goes on the suppression list. |
Open, Click | email.opened, email.clicked | Turn on open_tracking and click_tracking on the domain. |
Rendering Failure | Doesn't exist | A template variable with no value returns 422 at send time. |
Subscription (SES contact lists) | contact.updated | Applies to Postal's audience and broadcasts. |
- The identifier you store becomes the
idreturned byPOST /emails(data.email_idin the webhook), not the SESMessageId. - Tags come back in
data.tagsas an object, for example{"category": "shipping"}. - Postal also has events that SES does not, such as
email.scheduled,email.suppressed,email.receivedanddomain.updated. - Any 2xx response confirms delivery. Remove the SNS topic only after moving all traffic, because emails still leaving through SES keep generating events there. See Webhooks.
Bring over the SES suppression list
Anyone who already had a permanent bounce or complained on SES should not receive again. Export your account's suppression list and import it into Postal before the first send and before importing contacts, because a suppressed address never becomes a contact.
aws sesv2 list-suppressed-destinations --region us-east-1 > supressoes-ses.json
# se a resposta trouxer "NextToken", repita com --next-token "<valor>"
jq -r '.SuppressedDestinationSummaries[].EmailAddress' supressoes-ses.json \
| tr 'A-Z' 'a-z' | sort -u > emails.txtimport { readFileSync } from 'node:fs'
const emails = readFileSync('emails.txt', 'utf8').split('\n').filter(Boolean)
const headers = {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
}
for (let i = 0; i < emails.length; i += 100) {
const res = await fetch('https://api.postal.so/suppressions/batch/add', {
method: 'POST',
headers,
body: JSON.stringify({ emails: emails.slice(i, i + 100) }),
})
if (!res.ok) throw new Error(await res.text())
await new Promise((resolve) => setTimeout(resolve, 250)) // respeita o limite do plano
}- The SES list applies to the account and the region. Export once for each region you sent from. In the console, it is under Configuration > Suppression list.
- SES stores the address exactly as it came in, with upper and lower case. Postal compares case-insensitively, so the command above lowercases everything and removes duplicates.
- On Postal, addresses come in with the
manualorigin: the difference between SES'sBOUNCEandCOMPLAINTis not stored. - It needs a full-access key. For a few addresses, the dashboard accepts one at a time under Suppressions. See the suppressions reference.
Check quotas and cut over the traffic
Switch one flow at a time. Start with the test addresses and yourself, follow along in Emails and Logs and only then move the rest. Keep SES on until the events for the last old emails arrive.
| On SES | On Postal | |
|---|---|---|
| Sending rate | Your account's sending limit, per region | Requests per second, per team: Free 5, Pro 10, Scale 20, Enterprise 50. In a batch, 100 emails count as one request |
| Sending quota | Your account's, per region | Per plan. Free: 100 per day and 3,000 per month. Pro: 50,000 per month. Scale: 100,000 per month. Enterprise: custom. The daily limit also follows the account reputation: new accounts start at 500 |
| Sandbox | Until you request production access: 200 messages per 24 hours, 1 per second, only to verified addresses | You do not request sandbox exit on AWS. The limit is the plan's quota |
| Reputation | Your account's | Per team, from 0 to 100 points, starting at 0. Sending pauses when permanent bounces go over the level's limit (3% to 5%) or complaints over 0.3%. Before that, owners and admins get a warning |
- The test addresses (
delivered@,bounced@,complained@,suppressed@and[email protected]) do not use the quota or count against reputation. They depend on the plan. - See current values in Pricing and in Limits.
Before you flip the key
- The domain is
verifiedon Postal and DMARC is in order. - The API key was created (send-only and restricted to the domain, for systems that only send).
- A test email arrived and the headers show DKIM and SPF for your domain.
- The webhook is registered and your code checks the signature.
- The SES suppression list, from all regions, has been imported.
X-SES-*headers and SNS topics that are no longer needed have been identified.- The plan's quota and request limit cover your daily volume.
- Team owners and admins know what to do if sending pauses over reputation.
Frequently asked questions
Do I need to keep my AWS account?
To send through Postal, no: it uses its own SES account. Keep yours while any system still sends through it and until the events for the last emails arrive. After that, if you want, delete the SES identities and DNS records you no longer use.
My domain is already verified in SES. Can I verify it on Postal too?
Yes. Postal's DKIM uses the postal selector and the Return-Path lives on its own subdomain, so the records do not collide with SES's, and the main domain's SPF does not change. The exception is a Return-Path of yours that is already called postal: use another name with custom_return_path.
What happens to my sending reputation?
It is measured per team, with the account reputation: a score that starts at 0 and goes up with each day of sending without problems. New accounts start with a ceiling of 500 emails a day, which grows with the level. Postal pauses sending if permanent bounces go over the level's limit (3% to 5%) or complaints over 0.3%. Importing the SES suppression list helps you avoid repeating known bounces.
Can I use dedicated IPs?
Postal's documentation does not cover this. If your SES account uses dedicated IPs, talk to us through the contact form before migrating.