# Migrating from Mailgun

Keep the Mailgun client and its form-encoded send, and change the one line that says where it points. Bring the suppression lists first, from both regions.

emails.sh serves a Mailgun-compatible API at https://api.emails.sh/mailgun. It takes their form-encoded send at /v3/<domain>/messages with HTTP basic auth, and answers {"id":"<...@domain>","message":"Queued. Thank you."} with a 200, which is what their schema declares required and what their own SDK fixtures assert. Errors come back as {"message":"..."}, the single field their error schema documents. Their key becomes an esh_ key and goes in the same basic-auth password, with the same api username.

Import your suppression lists before you send anything real, and check your regions. Mailgun keeps bounces, complaints, and unsubscribes per domain, and suppressions are region-bound even though keys and domain names replicate globally. If any of your domains live in the EU region, `npx @emails.sh/cli migrate mailgun --region eu` is a second run you have to do, or every EU bounce is left behind.

The entire change:
```bash
// Node, mailgun.js
const mg = new Mailgun(FormData).client({
  username: 'api',
  key: 'esh_live_yourkey',
  url: 'https://api.emails.sh/mailgun'
});

# PHP
Mailgun::create($key, 'https://api.emails.sh/mailgun');

# Go v5, which rejects a base URL carrying a version
mg.SetAPIBase("https://api.emails.sh/mailgun")

# Go v4, which requires one, and is the only client of the twelve
# in this chapter that reads an environment variable at all
MG_URL=https://api.emails.sh/mailgun/v3
```

### Every language, and how to point it here

These were read from each SDK's source, not from its documentation. All seven are overridable, and only the Go client reads an environment variable, through the explicit NewMailgunFromEnv() entry point rather than ambiently. So this is a code change like Postmark and SendGrid rather than a config change like Resend.

| Language | Package | How to point it at emails.sh | Change |
| --- | --- | --- | --- |
| Node / TypeScript | mailgun.js | client({ username: 'api', key, url: 'https://api.emails.sh/mailgun' }) | client option |
| PHP | mailgun/mailgun-php | Mailgun::create($key, 'https://api.emails.sh/mailgun') | constructor |
| Ruby | mailgun-ruby | Mailgun::Client.new(key, 'api.emails.sh/mailgun') | host, no scheme |
| Python | mailgun | Client(auth=("api", key), api_url="https://api.emails.sh/mailgun") | constructor |
| Java | com.mailgun:mailgun-java | MailgunClient.config("https://api.emails.sh/mailgun", key) | builder |
| Go v5 | mailgun-go/v5 | mg.SetAPIBase("https://api.emails.sh/mailgun") | one line, no /v3 |
| Go v4 | mailgun-go/v4 | MG_URL=https://api.emails.sh/mailgun/v3 | env var, with /v3 |

The two Go rows differ for a real reason: v4 requires the version on the end of the base URL and v5 returns an error if you include it, so a v4 to v5 upgrade during a migration needs both changed at once. Ruby takes a bare host with no scheme, because it builds the URL from a separate secure flag. The Python client refuses a plain http:// host outright unless it is localhost, which is a good rule and not one you will hit here.

### What the compatibility endpoint covers

- `POST /mailgun/v3/:domain/messages` Send, form-encoded. Answers {"id":"<...>","message":"Queued. Thank you."} as theirs does.
- `GET /mailgun/v3/:domain/bounces` Your suppression list, in their {items, paging} envelope.
- `GET /mailgun/v3/:domain/complaints` Spam complaints.
- `GET /mailgun/v3/:domain/unsubscribes` Opt-outs.
- `GET /mailgun/v4/domains` Sending domains, in their {total_count, items} envelope.
- `POST /mailgun/v4/domains` Add one, and get back the DNS records to publish.
- `GET /mailgun/v3/domains` The same list, for clients pinned to the version before they moved it to v4.

A send, unchanged from theirs except the host and the key:
```bash
curl -s --user 'api:esh_live_yourkey'   https://api.emails.sh/mailgun/v3/yourdomain.com/messages   -F from='Acme <receipts@yourdomain.com>'   -F to=someone@example.com   -F subject='Your receipt'   -F text='Thanks for your order.'   -F o:tag=receipts   -F v:order-id=1234
```

These are a translation over the same /v1 handlers everything else uses, not a second implementation. A send through the compatibility endpoint passes the same suppression check, the same quota, the same spend cap, and the same idempotency table as a send through /v1/emails.

### What differs

- **The domain in the URL must match the from address**: At Mailgun the path names your sending domain. Here the path domain and the domain in from must agree, and a mismatch is refused rather than resolved silently in favour of one of them. A request whose URL and body disagree has a bug either way, and a silent success hides it.
- **recipient-variables**: Refused, and this is the field a Mailgun migration most often trips on. Batch sending with per-recipient substitution has a different shape here: POST /v1/emails/batch takes up to a hundred fully rendered messages in one call, each with its own subject and body. Render the per-recipient values in your own code and post the array. That also means each recipient gets their own message rather than seeing the others in the To header.
- **o:testmode**: Refused. There is no accept-and-discard mode, because a message that vanishes cannot be inspected. Send to a reserved test recipient instead: delivered@emails.sh, bounced@emails.sh, complained@emails.sh, or suppressed@emails.sh. Nothing reaches the internet, and you still get a real id, real delivery events and real webhooks, against no allowance. See /docs/sending.
- **Click tracking and the optimisation options**: o:tracking-clicks is refused because we do not rewrite links. o:deliverytime-optimize-period and o:time-zone-localize are refused because we do not model a recipient timezone: compute the moment yourself and pass o:deliverytime, which is honoured up to thirty days out. Simple o:deliverytime works and is parsed from RFC 2822 as theirs is.
- **Tags and variables**: Both translate. o:tag becomes a tag with an empty value, since theirs is a bare label and ours is a pair. v:name=value becomes a tag with its value, since their custom variables are arbitrary key/value metadata echoed on events, which is what our tags are. Both are readable on GET /v1/emails/:id.
- **Reply-To**: Mailgun has no reply_to field: it is set as h:Reply-To, and that is exactly how it arrives here. It is lifted out of the headers and validated as a reply-to rather than passed through as a raw header.
- **Routes**: A Route matches an inbound message and fires an action, and the message is gone unless you stored it. Inbound here is a mailbox: mail to a verified domain is stored, parsed, and threaded against what you sent, and you read and reply through the API. Routes that forward to a human address have no equivalent and should stay at Mailgun. The migrate command does not read them.
- **Regions**: Mailgun runs api.mailgun.net and api.eu.mailgun.net, and messages, logs, suppressions, routes, and mailing lists are region-bound between them. We run one API host, so there is nothing to choose on your side, but a migration has to read both of theirs. Where our own mail leaves from is a separate question: /docs/regions.
- **Message ids**: Theirs is an RFC 2392 message id in angle brackets and ours is a UUID, so we return the UUID as the local part: strip the brackets and the domain and you have the id GET /v1/emails/:id takes.

### Bringing the account across

The code is one line, but an account is also domains, webhooks, stored templates, and three suppression lists per domain per region. One command reads them and imports what it can. It prints a plan and waits for a yes before writing anything, and it is safe to run again.

Import the account:
```bash
npx @emails.sh/cli migrate mailgun
# reads MAILGUN_API_KEY, or pass --from-key
# add --region eu if any of your domains live in Mailgun's EU region:
# suppressions are region-bound there and would otherwise be left behind
# imports bounces, complaints, and unsubscribes for every domain first
```

Their bounce, complaint, and unsubscribe endpoints page on an opaque paging.next URL rather than on an offset, and skip does not work on them, so the command follows the cursor verbatim until a page comes back empty. That matters on a long-lived account: an importer that paged with skip would silently stop after the first hundred.

Suppressions are written straight to your list here before any domain is added, so an interrupted run is one where the half that finished is the half that mattered. A copy is saved as mailgun-suppressions.csv either way.

### Cutting over

- **Import the suppression lists, from every region** (/docs/troubleshooting): Before anything else, and twice if you have EU domains. Run migrate mailgun, then again with --region eu. Everything below this step is reversible; this one is not.
- **Publish our DNS records alongside theirs** (/docs/domains): Add the domain here and publish our DKIM, SPF, and return-path records. Mailgun's can stay while you run both: a domain may carry several DKIM selectors.
- **Point the client here and send one message** (/docs/quickstart): Change the url option, send to yourself, and check the id comes back. Branch on the status and the presence of id rather than on the message string: several near-miss spellings of "Queued. Thank you." exist in the wild.
- **Move sending first, MX last** (/docs/receiving): Send a percentage here and compare bounce and complaint rates for a week. Only change the MX record when you are satisfied, because inbound can point at one provider and that switch is the one that is hard to stage.

When you are ready to stop being compatible, move to /v1. It is a JSON body rather than a form, to is an array rather than a repeated field, h: headers become a headers object, v: variables become tags, and o:deliverytime becomes send_at as an RFC 3339 string. There is no deadline: the compatibility endpoint is a supported surface, not a temporary bridge.

---

Base URL: https://emails.sh/v1. Auth: `Authorization: Bearer esh_...`.
Whole API in one file: https://emails.sh/llms.txt. All documentation: https://emails.sh/docs.md.
