Switch from SparkPost (Bird) to Anypost
SparkPost's API uses a non-standard auth header and a deeply nested JSON body. Anypost flattens both. Budget about 45 minutes to update a typical integration.
SparkPost is now Bird Email. MessageBird acquired SparkPost
in 2021 and rebranded to Bird in 2024, folding SparkPost into its platform as
Bird Email. The API, the api.sparkpost.com endpoints, and the SDKs below
still operate under that product, so this guide applies whether your
dashboard says SparkPost or Bird.
What's the same, what changed
SparkPost's API has two quirks Anypost drops: a raw API key in the auth
header (no Bearer prefix) and a deeply nested request body. Both
flatten out on the Anypost side.
| Concept | SparkPost (Bird Email) | Anypost |
|---|---|---|
| Base URL | api.sparkpost.com/api/v1 | api.anypost.com/v1 |
| EU base URL | api.eu.sparkpost.com/api/v1 | api.anypost.com/v1 (unified) |
| Auth header | Authorization: <raw_api_key> | Authorization: Bearer ap_… |
| Key prefix | None (arbitrary string) | ap_ |
| Send endpoint | POST /api/v1/transmissions | POST /v1/email |
| Recipient format | recipients: [{address: {email, name}}] | to: ["email"] or ["Name <email>"] |
| Content wrapper | content: {from, subject, html, text} | Top-level from, subject, html, text |
| From address | content.from.email + content.from.name | from: "Name <email>" |
| Response shape | {results: {id, total_accepted_recipients}} | 202 Accepted, {id, created_at} |
| Tags / metadata | tags: [], metadata: {} | tags: ["string"] |
| Substitution data | substitution_data: {} | Template variables (server-side) |
| Webhooks | {msys: {message_event: {…}}} array | email.* names, batched array |
| SMTP host | smtp.sparkpostmail.com | smtp.anypost.com |
| SMTP username | SMTP_Injection (literal) | anypost |
| SMTP password | Your API key | Your ap_… API key |
The biggest gotcha: SparkPost omits Bearer from
the Authorization header and sends the raw key. Anypost uses standard Bearer
token auth, so add Bearer (with the trailing space) in front of
your key.
Four steps to switch
SparkPost and Anypost can both be active during cutover; your existing sending domain works with both. 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 your sending domain (the same one you use with SparkPost). You'll publish DNS records for SPF, DKIM, and tracking, then click verify. Propagation takes a few minutes to a few hours. See Domains.
Create an API key
In the Anypost dashboard, create a key (it starts with ap_). Unlike
SparkPost's arbitrary-string keys, Anypost keys are always prefixed, so
you can recognize them at a glance. Set it as ANYPOST_API_KEY. See
Authentication.
Flatten the request body and fix the auth header
This is the main code change. Move fields out of content and
recipients[].address to the top level, change to to a plain array of
strings, and add Bearer in front of your key in the Authorization
header.
POST /api/v1/transmissions
Authorization: YOUR_API_KEY
{
"recipients": [
{ "address": { "email": "[email protected]" } }
],
"content": {
"from": { "email": "[email protected]" },
"subject": "Welcome",
"html": "<p>Glad you're here.</p>"
}
}POST /v1/email
Authorization: Bearer ap_…
{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome",
"html": "<p>Glad you're here.</p>"
}Update your webhook endpoint
Register your webhook URL in the Anypost dashboard. SparkPost wraps each
event in a {msys: {message_event: {…}}} envelope; Anypost uses a flat
email.* event with a data object. Update your handler to read the new
structure and remap the event names. See the Webhooks
section below.
The same change in every SDK
The changes are the same everywhere: standard Bearer auth, a flat JSON body, and a simpler recipient and from format. Pick your language.
curl https://api.sparkpost.com/api/v1/transmissions \
-H "Authorization: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": [{"address": {"email": "[email protected]"}}],
"content": {
"from": {"email": "[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
SparkPost wraps every event in a {msys: {message_event: {…}}}
envelope and posts an array of them. Anypost posts its events in a batched
events array, each a flat email.* object with a
data payload.
| SparkPost event | Anypost event | Notes | |
|---|---|---|---|
injection | email.sent | rename | Message accepted by the platform |
delivery | email.delivered | rename | Accepted by the recipient's server |
delay | email.delayed | rename | Temporary failure; will be retried |
bounce | email.bounced | rename | Permanent failure |
out_of_band | email.bounced | split | Asynchronous bounce; same Anypost event |
spam_complaint | email.complained | rename | Feedback-loop complaint |
open | email.opened | rename | Tracked open |
click | email.clicked | rename | Tracked click |
policy_rejection | email.suppressed | split | Suppression-list drops; other policy blocks are synchronous API errors |
generation_failure | (none) | no equiv. | Template errors are synchronous API errors, not events |
Two SparkPost events are easy to overlook: delay has a direct
equivalent in email.delayed (Anypost emits it during transient
retries), and out_of_band folds into email.bounced.
The only events without an analog are generation/template failures, which
Anypost returns synchronously at send time instead of as a webhook.
[
{
"msys": {
"message_event": {
"type": "delivery",
"rcpt_to": "[email protected]",
"timestamp": "2026-01-15T10:30:00Z"
}
}
}
]{
"batch_id": "9c1f2a7b…",
"events": [
{
"type": "email.delivered",
"occurred_at": "2026-01-15T10:30:00Z",
"data": {
"email_id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d",
"recipient": "[email protected]"
}
}
]
}// SparkPost: iterate the array, unwrap the msys envelope
app.post("/webhook/email", (req, res) => {
for (const batch of req.body) {
const event = batch.msys.message_event;
if (event.type === "delivery") markDelivered(event.rcpt_to);
if (event.type === "bounce") suppressAddress(event.rcpt_to);
}
res.sendStatus(200);
});// Anypost: iterate events, flat names, 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": suppressAddress(data.recipient); break;
case "email.delayed": scheduleRetryNotice(data.email_id); break;
case "email.complained": unsubscribe(data.recipient); break;
case "email.opened":
case "email.clicked": recordEngagement(data); break;
}
}
res.sendStatus(200);
});Signature verification differs. SparkPost signs its
webhooks with a Basic-auth or token check you configure; 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.
Using SMTP instead of the API?
SparkPost's SMTP requires the literal username SMTP_Injection,
one of the more unusual conventions around. Anypost uses anypost
with your ap_ key as the password. Update four values and you're
done.
SMTP_HOST=smtp.sparkpostmail.com
SMTP_PORT=587
SMTP_USER=SMTP_Injection
SMTP_PASS=YOUR_API_KEY
SMTP_TLS=trueSMTP_HOST=smtp.anypost.com
SMTP_PORT=587
SMTP_USER=anypost
SMTP_PASS=ap_…
SMTP_TLS=trueSparkPost EU customers use smtp.eu.sparkpostmail.com; Anypost
needs no separate EU endpoint, since every region connects to
smtp.anypost.com. Supported ports are 587
(recommended), 2587, and 25, with STARTTLS required
on every port. See Sending over SMTP.
Ready to switch?
Create your account, verify your domain, and send your first email in minutes.