Switch from Resend to Anypost
If you're coming from Resend, Anypost will feel familiar: both are JSON REST APIs with Bearer auth and a similar set of core send fields. For most apps, migrating means changing your package name, swapping your API key, and adjusting a field or two.
What's the same, what changed
Both platforms send authenticated email over a JSON REST API and report the same delivery lifecycle. The core request looks similar; the differences are a handful of details, listed below.
| Concept | Resend | Anypost |
|---|---|---|
| Base URL | api.resend.com | api.anypost.com/v1 |
| Auth header | Authorization: Bearer re_… | Authorization: Bearer ap_… |
| Key prefix | re_ | ap_ |
| Send endpoint | POST /emails | POST /v1/email |
| Batch endpoint | POST /emails/batch, a JSON array | POST /v1/email/batch, { emails: [...] }, up to 100 |
| Response id | id | id |
from, to, cc, bcc | Same names | Same names |
reply_to | Same name | Same name |
html / text | Same names | Same names |
| Attachments | Base64 in JSON | Base64 in JSON |
| Idempotency | Idempotency-Key header | Idempotency-Key header |
| Tags | [{name, value}] objects | ["string"] (array of strings) |
| Templates | react-email / HTML string | Markdown or HTML, stored server-side |
| Webhooks | email.* events, one per request | email.* events, batched array |
| SMTP host | smtp.resend.com | smtp.anypost.com |
| SMTP port | 465 (implicit TLS) | 587 (STARTTLS) |
The one field to watch: Resend's tags take an
array of { name, value } objects. Anypost's tags
take plain strings, like ["welcome", "onboarding"]: up to 10, each up to 64 letters, numbers, underscores, or hyphens. Flatten each tag object into one string, such as flow_welcome (a colon is rejected), and the rest of the body carries over.
See Tags, topics & campaigns.
Six steps to switch
You can run Resend and Anypost in parallel during cutover; the two domains are independent. Migrate one email type at a time and verify delivery before cutting over completely.
Create an Anypost account and verify your domain
Sign up at anypost.com and add the same sending domain you use with Resend. 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. Propagation takes a few minutes to a few hours. Your Resend records don't conflict with Anypost's, so both can be live at once. 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 dashboard, create a key (it starts with ap_). Use a send-only
key for your application server, just as you would for a Resend key.
Set it as ANYPOST_API_KEY alongside your existing RESEND_API_KEY
during cutover. See Authentication.
Swap the package and key
Install the Anypost SDK, replace your Resend import, and point the key
env var at your new ap_ key. The send call keeps a similar shape.
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: "[email protected]",
to: ["[email protected]"],
subject: "Hello",
html: "<p>Hi there.</p>",
});import { Anypost } from "anypost";
const anypost = new Anypost(process.env.ANYPOST_API_KEY);
await anypost.email.send({
from: "[email protected]",
to: ["[email protected]"],
subject: "Hello",
html: "<p>Hi there.</p>",
});Update your webhook endpoint (if you use one)
Register your existing webhook URL in the Anypost dashboard. Most email.* event names carry over, so your switch statement needs only minor changes: rename the two the table below lists, and wrap it in a loop because Anypost batches events into an array.
See the Webhooks section below.
Bring your suppression list
Export the addresses Resend has stopped sending to, and your unsubscribed contacts, 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 call in every SDK
The change is consistent across languages: swap the package, construct the
client with your ap_ key, and call email.send. Pick your language.
curl https://api.resend.com/emails \
-H "Authorization: Bearer re_…" \
-H "Content-Type: application/json" \
-d '{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome",
"html": "<p>Hi.</p>"
}'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>"
}'Webhooks
Most of the email.* event names you handle today carry over unchanged;
the table marks the two that don't. Beyond those, Anypost delivers events
in a batched events array, and the recipient address is
data.recipient rather than data.to.
| Resend event | Anypost event | Notes | |
|---|---|---|---|
email.sent | email.sent | same | Accepted for delivery |
email.delivered | email.delivered | same | Accepted by the recipient's server |
email.delivery_delayed | email.delayed | rename | Temporary failure; will be retried |
email.bounced | email.bounced | same | Check bounce.type: permanent, transient, or expired |
email.complained | email.complained | same | Feedback-loop complaint |
email.opened | email.opened | same | Tracked open |
email.clicked | email.clicked | same | Tracked click |
email.failed | (none) | no equiv. | A send Anypost can't accept is an API error at send time |
Anypost also sends email.suppressed when a send skips an
address on your suppression list, and email.unsubscribed when
a recipient uses a one-click unsubscribe link.
// Resend posts one event per request
app.post("/webhook/email", (req, res) => {
const { type, data } = req.body;
switch (type) {
case "email.delivered": markDelivered(data.email_id); break;
case "email.bounced": suppressAddress(data.to); break;
case "email.complained": unsubscribe(data.to); break;
case "email.opened":
case "email.clicked": recordEngagement(data); break;
}
res.sendStatus(200);
});// Anypost batches events; the recipient is data.recipient
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. Resend signs webhooks with
Svix headers; Anypost signs with an Anypost-Signature header
(t=<unix>,v1=<hmac>) over the timestamp and raw
body. Swap your verification step for Anypost's before trusting a delivery.
See Webhooks.
Register your webhook URL once in the Anypost dashboard and select which events to receive. One URL covers every event for your account.
Using SMTP instead of the API?
Point your SMTP client at Anypost's host with the username anypost
and your ap_ key as the password. The one real change is the
port: Resend uses implicit TLS on 465; Anypost uses STARTTLS on
587.
SMTP_HOST=smtp.resend.com
SMTP_PORT=465
SMTP_USER=resend
SMTP_PASS=re_…
SMTP_TLS=trueSMTP_HOST=smtp.anypost.com
SMTP_PORT=587
SMTP_USER=anypost
SMTP_PASS=ap_…
SMTP_STARTTLS=trueAnypost supports ports 587 (recommended), 2587, and
25. STARTTLS is required on every port, and there's no
implicit-TLS port, so move off Resend's 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.