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.
Five 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 you'll publish three DNS records (an SPF, a DKIM, and a tracking CNAME). 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. 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 cleanly into email.bounced (permanent) and email.delayed
(temporary) in Anypost. 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.
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 | 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 you can suppress on bounces without reacting to retries.
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": 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_TLS=trueSMTP_HOST=smtp.anypost.com
SMTP_PORT=587
SMTP_USER=anypost
SMTP_PASS=ap_…
SMTP_TLS=trueCredential 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?
Create your account, verify your domain, and send your first email in minutes.