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, failedtype 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.

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.

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 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.

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.bouncedsplit
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 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.

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

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?

Create your account, verify your domain, and send your first email in minutes.