Switch from SendGrid to Anypost
SendGrid and Anypost share the same concepts: domains, API keys, sends, and delivery events. The APIs look different, though. The biggest change is SendGrid's personalizations wrapper, which Anypost replaces with a flat, simpler JSON body.
What's the same, what changed
Both platforms send authenticated email over HTTP and SMTP, report delivery events via webhooks, and authenticate with API keys. The main difference is how you structure a send request.
| Concept | SendGrid | Anypost |
|---|---|---|
| Base URL | api.sendgrid.com/v3 | api.anypost.com/v1 |
| Send endpoint | POST /v3/mail/send | POST /v1/email |
| Auth header | Authorization: Bearer SG.… | Authorization: Bearer ap_… |
| Key prefix | SG. | ap_ |
| Request body | personalizations array + top-level from / content | Flat: from, to, subject, html, text |
| to / cc / bcc | Inside personalizations[0] | Top-level to, cc, bcc |
| subject | personalizations[0].subject or top-level | Top-level subject |
| html / text body | content: [{type, value}] array | html / text (flat fields) |
| reply_to | reply_to: {email, name} | reply_to: "addr" or array |
| Attachments | {content, filename, type, disposition, content_id} | {content, filename, content_type, content_id}; drop disposition, rename type |
| Batch send | Multiple personalizations in one request | POST /v1/email/batch |
| Tags / categories | categories: ["string"] | tags: ["string"] |
| Dynamic templates | template_id + dynamic_template_data | template_id + variables |
| Successful response | 202 Accepted, no body | 202 Accepted, {id, created_at} |
| Webhooks | Event Webhook, flat event names | email.* namespaced names, batched |
| SMTP host | smtp.sendgrid.net | smtp.anypost.com |
| SMTP username | apikey (literal string) | anypost |
| SMTP password | Your SG.… key | Your ap_… key |
The personalizations wrapper is the key change. SendGrid
wraps recipients, subject, and per-message substitutions in a
personalizations array. Anypost uses a flat body:
from, to, subject,
html/text at the top level. Most other differences
follow from this.
Seven steps to switch
You can run SendGrid and Anypost in parallel during cutover; they are independent services. Switch 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 SendGrid. 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 SendGrid domain setup doesn't transfer, so configure the domain fresh in Anypost. 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_). Use a
send-only key for your application server. Set it as ANYPOST_API_KEY
alongside your existing SENDGRID_API_KEY during cutover. See
Authentication.
Swap the package and flatten your send call
Replace your SendGrid SDK with the Anypost SDK, then rewrite the send
call. The main work is converting SendGrid's personalizations
structure to Anypost's flat fields. The content array
([{type, value}]) becomes top-level html / text fields.
import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
await sgMail.send({
personalizations: [{
to: [{ email: "[email protected]" }],
subject: "Hello",
}],
from: { email: "[email protected]" },
content: [{
type: "text/html",
value: "<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 webhook event names
Anypost reports the same delivery lifecycle as SendGrid, but the type
strings differ; Anypost namespaces them under email.*. Update your
switch statement to the new names. See the Webhooks section
below for the full mapping.
Check your response handling
SendGrid returns 202 Accepted with an empty body. Anypost also returns
202, but with a JSON body: {"id": "email_…", "created_at": "…"}. The
status code check still passes; capture the id to correlate delivery
events later. See Send an email.
Bring your suppression list
Export SendGrid's bounce, invalid email, spam report, and unsubscribe lists 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 structural difference is consistent across every language: flatten
personalizations and replace the content array with html / text
fields. Pick your language.
curl https://api.sendgrid.com/v3/mail/send \
-H "Authorization: Bearer SG.…" \
-H "Content-Type: application/json" \
-d '{
"personalizations": [{
"to": [{"email": "[email protected]"}],
"subject": "Welcome"
}],
"from": {"email": "[email protected]"},
"content": [{
"type": "text/html",
"value": "<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>"
}'Sending to multiple recipients: In SendGrid you'd add
multiple personalizations entries (or several addresses inside
one). In Anypost, pass multiple addresses in the to array, or
use POST /v1/email/batch for fully independent per-recipient
messages. See Batch sending.
Webhooks
Anypost uses email.* namespaced event types. The lifecycle is
the same one you're already handling; the type strings change and the
payload arrives in a batched events array.
| Lifecycle moment | SendGrid event | Anypost event type | |
|---|---|---|---|
| Accepted / queued | processed | email.sent | rename |
| Delivered to inbox | delivered | email.delivered | rename |
| Temporary failure / retry | deferred | email.delayed | rename |
| Hard bounce | bounce | email.bounced | rename |
| Dropped before send | dropped | email.suppressed | rename |
| Spam complaint | spamreport | email.complained | rename |
| Email opened | open | email.opened | rename |
| Link clicked | click | email.clicked | rename |
| Unsubscribe | unsubscribe | email.unsubscribed | rename |
Every SendGrid lifecycle event has an Anypost equivalent, including ones you
may have treated as SendGrid-specific: processed becomes
email.sent, deferred becomes
email.delayed, and dropped (a suppression-list
drop) becomes email.suppressed. SendGrid's subscription-group
events (group_unsubscribe, group_resubscribe) have
no analog; manage opt-outs through Anypost's suppression
list instead.
// SendGrid posts an array of events
app.post("/webhook/email", (req, res) => {
for (const event of req.body) {
switch (event.event) {
case "delivered": markDelivered(event.sg_message_id); break;
case "bounce": suppressAddress(event.email); break;
case "deferred": scheduleRetryNotice(event.sg_message_id); break;
case "spamreport": unsubscribe(event.email); break;
case "open": recordOpen(event); break;
case "click": recordClick(event.url); break;
}
}
res.sendStatus(200);
});// Anypost posts a batched events array; 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.delayed": scheduleRetryNotice(data.email_id); break;
case "email.complained": unsubscribe(data.recipient); break;
case "email.opened": recordOpen(data); break;
case "email.clicked": recordClick(data.tracking.url); 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. SendGrid signs its Event
Webhook with an ECDSA public key; Anypost signs with an
Anypost-Signature header (t=<unix>,v1=<hmac>)
over the timestamp and raw body. Swap your verification step before trusting
a delivery. See Webhooks.
Register your webhook URL once in the Anypost dashboard and select which events to receive. The HTTPS endpoint and 200-response contract work the same way; only the event strings and payload shape change.
Using SMTP instead of the API?
Update the host, username, and password. SendGrid's SMTP username is the
literal string apikey; Anypost uses anypost, with
your ap_ key as the password.
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=SG.…
SMTP_STARTTLS=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 if your SendGrid config uses port 465,
switch 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.