Migrate/Mailgun
Auth + format change

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.

~45 mintypical migration time
2structural changes (auth + format)
8official SDK languages

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.

ConceptMailgunAnypost
Base URLapi.mailgun.net/v3/api.anypost.com/v1/
Send endpointPOST /v3/{domain}/messagesPOST /v1/email
Domain in URLYes, part of the pathNo, set at the account/domain level
Auth methodHTTP Basic (user api, password = key)Bearer token
Key prefixNo standard prefixap_
Request formatmultipart/form-dataapplication/json
from, to, subjectForm fields (flat)JSON fields (flat)
html / text bodyhtml=… text=… form fields"html": "…" "text": "…" JSON
Multiple recipientsRepeat the to= fieldto: ["[email protected]", "[email protected]"]
cc / bcccc=… bcc=… form fieldscc: […] bcc: […] JSON arrays
Attachmentsmultipart file uploadBase64-encoded in JSON
Tagso:tag=… (repeatable field)tags: ["string"] JSON array
Templatestemplate=name + t:variables={}template_id + variables: {}
Batch sendrecipient-variables in one requestPOST /v1/email/batch
Successful response200 OK, {id, message}202 Accepted, {id, created_at}
Webhooksevent field: delivered, failed…type field: email.delivered, email.bounced…
SMTP hostsmtp.mailgun.orgsmtp.anypost.com
SMTP usernamePer-domain SMTP credentialanypost
SMTP passwordPer-domain SMTP passwordYour ap_… API key
EU regionapi.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.

Before (Mailgun)
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>'
After (Anypost)
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.

Mailgun
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>'
Anypost
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 momentMailgun eventAnypost event type
Accepted for deliveryacceptedemail.sentrename
Delivered to inboxdeliveredemail.deliveredrename
Hard bouncefailed (severity: permanent)email.bounced, bounce.type: "permanent"split
Soft / temp failurefailed (severity: temporary)email.delayedsplit
Spam complaintcomplainedemail.complainedrename
Email openedopenedemail.openedrename
Link clickedclickedemail.clickedrename
Unsubscribeunsubscribedemail.unsubscribedrename

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.

Mailgun SMTP
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USER[email protected]
SMTP_PASS=<domain-smtp-password>
SMTP_STARTTLS=true
Anypost SMTP
SMTP_HOST=smtp.anypost.com
SMTP_PORT=587
SMTP_USER=anypost
SMTP_PASS=ap_…
SMTP_STARTTLS=true

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.

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.