Switch from Mailgun to Anypost
Mailgun and Anypost both solve the same problem, but their APIs differ in two key ways: Mailgun uses HTTP Basic auth with form-encoded requests; Anypost uses a Bearer token with JSON. Once you understand those two shifts, the rest follows.
What's the same, what changed
Both platforms send authenticated email, report delivery events, and support domain-based sender identity. The differences are mostly in the wire format: how you authenticate and how you shape the request body.
| Concept | Mailgun | Anypost |
|---|---|---|
| Base URL | api.mailgun.net/v3/ | api.anypost.com/v1/ |
| Send endpoint | POST /v3/{domain}/messages | POST /v1/email |
| Domain in URL | Yes, part of the path | No, set at the account/domain level |
| Auth method | HTTP Basic (user api, password = key) | Bearer token |
| Key prefix | No standard prefix | ap_ |
| Request format | multipart/form-data | application/json |
| from, to, subject | Form fields (flat) | JSON fields (flat) |
| html / text body | html=… text=… form fields | "html": "…" "text": "…" JSON |
| Multiple recipients | Repeat the to= field | to: ["[email protected]", "[email protected]"] |
| cc / bcc | cc=… bcc=… form fields | cc: […] bcc: […] JSON arrays |
| Attachments | multipart file upload | Base64-encoded in JSON |
| Tags | o:tag=… (repeatable field) | tags: ["string"] JSON array |
| Templates | template=name + t:variables={} | template_id + variables: {} |
| Batch send | recipient-variables in one request | POST /v1/email/batch |
| Successful response | 200 OK, {id, message} | 202 Accepted, {id, created_at} |
| Webhooks | event field: delivered, failed… | type field: email.delivered, email.bounced… |
| SMTP host | smtp.mailgun.org | smtp.anypost.com |
| SMTP username | Per-domain SMTP credential | anypost |
| SMTP password | Per-domain SMTP password | Your ap_… API key |
| EU region | api.eu.mailgun.net (separate URL) | Single global endpoint |
Two things to rewire: (1) Auth: swap
--user api:KEY Basic auth for an
Authorization: Bearer ap_… header. (2) Format: change your
request body from form-encoded fields to a JSON object. The field names
(from, to, subject, html,
text) stay the same.
Domain in the URL: Mailgun includes your sending domain in
every API path, like /v3/mg.example.com/messages. Anypost does
not.
You verify your domain once in the dashboard; the API endpoint is always
/v1/email.
Seven steps to switch
You can run Mailgun and Anypost in parallel during cutover; they are fully 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 Mailgun, and publish three CNAME records (one for verification, two for DKIM); Anypost hosts the SPF and DKIM records they point to, and on Cloudflare one click creates them for you. Verification takes a few minutes to a few hours. Your Mailgun DNS 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 Anypost dashboard, create a key (it starts with ap_). Use a
send-only key for your application server. Set it as ANYPOST_API_KEY
in your environment alongside your existing MAILGUN_API_KEY during
cutover. See Authentication.
Swap the package, auth, and request format
The biggest change: Mailgun uses HTTP Basic auth and multipart
form-data; Anypost uses a Bearer token and JSON. The field names are the
same (from, to, subject, html); you just change how they're
sent.
curl --user 'api:YOUR_KEY' \
https://api.mailgun.net/v3/mg.example.com/messages \
-F from='[email protected]' \
-F to='[email protected]' \
-F subject='Hello' \
-F html='<p>Hi there.</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": "Hello",
"html": "<p>Hi there.</p>"
}'Update your webhook event names
Anypost uses email.* namespaced event types. Mailgun's failed
event, which covers both hard and soft bounces via a severity field,
splits in Anypost: email.delayed for each temporary failure that will be retried, and email.bounced when delivery ends, with a bounce.type of permanent, transient, or expired. See the Webhooks section
for the full mapping.
Update your tags syntax
Mailgun uses a repeatable o:tag form field. Anypost takes a JSON
tags array. Collect all your o:tag values and pass them as
"tags": ["tag1", "tag2"] in the JSON body. See
Tags, topics & campaigns.
Bring your suppression list
Export Mailgun's bounce, complaint, 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 pattern is consistent: remove the Basic auth setup, remove the domain from the URL, switch from form fields to JSON keys. Pick your language.
curl --user 'api:YOUR_KEY' \
https://api.mailgun.net/v3/mg.example.com/messages \
-F from='Acme <[email protected]>' \
-F to='[email protected]' \
-F subject='Welcome' \
--form-string html='<p>Glad you are here.</p>'curl https://api.anypost.com/v1/email \
-H 'Authorization: Bearer ap_…' \
-H 'Content-Type: application/json' \
-d '{
"from": "Acme <[email protected]>",
"to": ["[email protected]"],
"subject": "Welcome",
"html": "<p>Glad you are here.</p>"
}'Tags: Replace each -F o:tag='my-tag' form field
with a "tags": ["my-tag", "other-tag"] JSON array. Multiple
o:tag values collapse into one array.
Event names changed, lifecycle didn't
Mailgun uses a flat event string with a severity
sub-field. Anypost namespaces events under email.* and gives
permanent and temporary failures their own event types.
| Lifecycle moment | Mailgun event | Anypost event type | |
|---|---|---|---|
| Accepted for delivery | accepted | email.sent | rename |
| Delivered to inbox | delivered | email.delivered | rename |
| Hard bounce | failed (severity: permanent) | email.bounced, bounce.type: "permanent" | split |
| Soft / temp failure | failed (severity: temporary) | email.delayed | split |
| Spam complaint | complained | email.complained | rename |
| Email opened | opened | email.opened | rename |
| Link clicked | clicked | email.clicked | rename |
| Unsubscribe | unsubscribed | email.unsubscribed | rename |
Mailgun's failed event is two things in one.
Check the severity field: "permanent" is a hard
bounce (map to email.bounced); "temporary" is a
transient delivery failure (map to email.delayed). Anypost
splits them, so a retry never looks like a bounce. When delivery ends,
email.bounced says how in bounce.type, and only
permanent is worth suppressing.
Anypost also emits email.suppressed when a send is dropped because the address
is already on your suppression list. There's no Mailgun equivalent, and you
don't need to handle it to migrate.
// Before (Mailgun event names)
switch (payload["event-data"].event) {
case "delivered":
markDelivered(payload["event-data"].message.headers["message-id"]);
break;
case "failed":
if (payload["event-data"].severity === "permanent")
suppressAddress(payload["event-data"].recipient);
break;
case "complained": unsubscribe(payload["event-data"].recipient); break;
case "opened": recordOpen(payload["event-data"]); break;
case "clicked": recordClick(payload["event-data"].url); break;
}
// After (Anypost event names; events arrive in a batched array)
for (const event of req.body.events) {
switch (event.type) {
case "email.delivered": markDelivered(event.data.email_id); break;
case "email.bounced":
if (event.data.bounce.type === "permanent") suppressAddress(event.data.recipient);
break;
case "email.delayed": scheduleRetryNotice(event.data.email_id); break;
case "email.complained": unsubscribe(event.data.recipient); break;
case "email.opened": recordOpen(event.data); break;
case "email.clicked": recordClick(event.data.tracking.url); break;
}
}Register your existing HTTPS webhook URL in the Anypost dashboard. The endpoint and 200-response contract are identical; only the JSON payload shape changes. See Webhooks.
Using SMTP instead of the API?
Mailgun uses per-domain SMTP credentials, a separate username and password for each domain, managed in the dashboard. Anypost uses a single API key as the password across all your domains.
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USER[email protected]
SMTP_PASS=<domain-smtp-password>
SMTP_STARTTLS=trueSMTP_HOST=smtp.anypost.com
SMTP_PORT=587
SMTP_USER=anypost
SMTP_PASS=ap_…
SMTP_STARTTLS=trueSuppress 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.
Credential simplification: Mailgun's per-domain SMTP
passwords are separate from your API key and managed per domain. Anypost's
SMTP password is just your ap_ API key, and one credential
works for all your verified domains.
Anypost supports ports 587 (recommended), 2587, and 25. STARTTLS is
required on every port, and there is no implicit-TLS port, so if your current
Mailgun 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.