Migrate from Resend to Postal
Postal's API uses the same resources, field names and response formats as Resend's API. In most cases, migrating means changing the base URL and the key, and the Resend SDK keeps working.
- Changes in code
- URL and key
- You can run both together
- Yes
- Estimated time, plus DNS
- A few hours
- Type of sending
- API and SMTP
Verify the domain on Postal
Add the domain in the dashboard and create the DNS records it shows. You can keep the same domain on Resend and Postal at the same time: the records are different, because Postal uses the postal selector in DKIM.
- Postal checks the records on its own for up to 72 hours and notifies you by webhook (
domain.updated) - If the domain doesn't have DMARC yet, the large providers penalize senders of volume: start with
p=noneand tighten it later - Create an API key under API keys. For systems that only send, use a sending-only key, restricted to the domain
Point the SDK at Postal
Resend's official SDKs work when pointed at Postal. In Node.js, pass baseUrl when creating the client or set RESEND_BASE_URL. The email body, the response ({"id"}) and the error format (statusCode, name, message) stay the same.
Through the SDK
- const resend = new Resend(process.env.RESEND_API_KEY);+ const resend = new Resend(process.env.POSTAL_API_KEY, { baseUrl: 'https://api.postal.so' });Or just through the environment
# No changes to code that already uses new Resend(key)
RESEND_BASE_URL=https://api.postal.so
RESEND_API_KEY=ps_your_keyOver HTTP
- curl -X POST 'https://api.resend.com/emails' -H 'Authorization: Bearer re_...'+ curl -X POST 'https://api.postal.so/emails' -H 'Authorization: Bearer ps_...'Over SMTP
- Server: smtp.resend.com- User: resend- Password: re_...+ Server: smtp.postal.so+ User: postal (resend is also accepted)+ Password: ps_...Tested with the resend 6.34 package (Node.js): simple, scheduled, batch and idempotent sending, plus lookup, editing and cancellation, attachments, domains, API keys, webhooks and webhooks.verify. Other SDKs that let you change the base URL speak the same format, but they were not tested one by one: if you find a difference, let us know through the contact form.
Import what you already have
Before switching traffic, bring over the suppression list, so you don't write to people who already asked to opt out, and the contacts, if you use the audience. Recreate the templates too: the {{{VAR}}} variables are the same as Resend's.
- Suppression list:
POST /suppressions/batch/add, with 100 addresses per request - Contacts: CSV import, through the dashboard or the API
- Templates: create and publish with the same alias and the same variables; sends always use the latest published version
curl -X POST https://api.postal.so/suppressions/batch/add \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"emails": ["[email protected]", "[email protected]"]}'Recreate the webhook and check the signature
Event names are the same (email.delivered, email.bounced…), in the format {type, created_at, data}. What changes is the secret: in Postal, each endpoint has its own signing_secret (whsec_…). The signature follows the Standard Webhooks standard, and resend.webhooks.verify accepts both the webhook-* and svix-* headers.
- Create the endpoint under Webhooks (dashboard or
POST /webhooks) and choose the events - Send a test event from the dashboard before switching traffic
- There are 8 attempts over about 27 hours, always with the same
webhook-id; manual resending goes out through the dashboard orPOST /webhooks/{id}/events/{event_id}/replay - Other languages: see the signature verification
import { Resend } from 'resend';
const resend = new Resend(process.env.POSTAL_API_KEY, { baseUrl: 'https://api.postal.so' });
// Read the raw request body, without reformatting the JSON
const event = resend.webhooks.verify({
payload: rawBody,
headers: {
id: request.headers['webhook-id'],
timestamp: request.headers['webhook-timestamp'],
signature: request.headers['webhook-signature'],
},
webhookSecret: process.env.POSTAL_WEBHOOK_SECRET, // whsec_...
});Test and switch, with both close at hand
Change the base URL and the key in the test environment, send an email to yourself and follow it in Logs and in the webhooks. Test addresses accept +rótulo, don't use quota, don't affect reputation and generate real webhooks (depending on the plan). Then switch in production and keep Resend around for a few days.
- Different IDs. Resend's email and webhook IDs don't exist in Postal, and the reverse is also true. Record which provider each email went out through and keep receiving events from Resend for what went out through it
- Mark the origin. Use
tags(for example,provider: postal) and your ownIdempotency-Keyon each send - One provider at a time per flow. Invitations, password resets and codes should go out through a single path. Watch the bounces in the first few days, because the reputation limit applies to the whole team
- An address that works again. A permanent bounce puts the address on the suppression list; remove it with
DELETE /suppressions/{id}orPOST /suppressions/batch/remove
| On Resend | On Postal |
|---|---|
[email protected] | [email protected] |
[email protected] | [email protected] |
[email protected] | [email protected] |
[email protected] | [email protected] |
| no equivalent | [email protected], which Postal adds |
What changes from one to the other
| Item | What changes in Postal |
|---|---|
| Base URL and key | https://api.postal.so and Authorization: Bearer ps_…. Full-access or sending-only keys, optionally restricted to a domain. |
| Sending and response | POST /emails, returns {"id"}. Same. |
| Errors | {"statusCode", "name", "message"}. Same. See Errors. |
| Webhooks and signature | Standard Webhooks standard: webhook-id, webhook-timestamp and webhook-signature, and the same values in svix-*. The secret is each endpoint's signing_secret. |
| Event names | The same ones, plus email.replied, contact.*, form.submitted, broadcast.sent and sending.paused / sending.resumed. |
| Bounce and failure reason | data.bounce.message and data.failed.reason, with the same fields. |
| Attachments | filename and content in base64, plus path (URL) and content_id for inline images. Up to 40 MB per email; your plan may allow less. |
| Idempotency | Idempotency-Key header (1 to 256 characters, kept for 24 hours) on POST /emails, /emails/batch and /events. |
| Scheduling | scheduled_at in ISO 8601, in the future and at most 30 days ahead. You can reschedule and cancel. |
| Limits and quotas | Each plan has its own requests per second and sending quotas. See Limits and Pricing. |
| Reputation pause | Permanent bounces above the limit of the account reputation level (3% to 5%), complaints above 0.3% or rejected content above 5% pause the team's sending: 403 sending_paused. New accounts start at level 1, with a ceiling of 500 emails a day. Before the pause, owners and admins get an email warning. |
| Sending regions | São Paulo (sa-east-1, the default), us-east-1, us-east-2, eu-west-1 and ap-northeast-1, per domain. |
| Billing | In reais, dollars or euros. The public list of plans is at GET /plans. |
| Retention | Emails, webhook events and logs are kept for 30 days; events sent through POST /events, for 90. |
What Postal adds
- Processing region of your choice, with São Paulo as the default. How we protect your data
- Inboxes to receive and reply to emails, with conversations, folders, tags and drafts. See Inboxes
- Audience and marketing: contacts, segments, topics, forms, broadcasts and automations, with the same key. See email marketing
- Integrations with Stripe, Supabase, Clerk, Auth0 and Firebase, and the
postalCLI. Integrate your app
The full reference is in Migrate from Resend, in the documentation.
Before you flip the key
- Domain verified in Postal and API key stored in the environment
- Suppression list and contacts imported
- Templates recreated and published, with the same variables
- Webhook created and signature checked with a test event
- A test email sent and seen in Logs
- Your code records which provider each email went out through
- Bounces checked in the first days after the switch
Frequently asked questions
Do I need to change the code that uses the Resend SDK?
In Node.js, just pass baseUrl when creating the client or set RESEND_BASE_URL, besides changing the key. Other SDKs that let you change the base URL speak the same format, but they were not tested one by one.
Can I keep the same domain on both platforms?
Yes. The DNS records are different (Postal uses the postal selector), so both stay verified at the same time during the switch.
Does the code that verifies webhook signatures change?
resend.webhooks.verify keeps working, because Postal accepts both the webhook-* and svix-* headers. What changes is the secret: use the whsec_… of the endpoint created in Postal.
Do the IDs of my emails stay valid?
No. IDs belong to each platform. Also record which provider each email went out through, so you check the status in the right place.
Keep going
Other guides
Each platform has its own: the general step-by-step is in Migrate.
Ready to change the base URL?
Create your account and start on the Free plan. To plan your quota and the order of the migration, talk to us through the contact form.