---
title: "Migrate from Mailgun to Unitpost"
description: "Leave Mailgun in order: import lists and per-domain suppressions, check EU domains, re-verify DNS, rebuild routes as receiving, move to JSON and Bearer auth."
url: https://www.unitpost.com/migration/mailgun
section: Migration
updated: 2026-11-02
---
# Migrate from Mailgun to Unitpost

To leave Mailgun, paste your account Private API key into Unitpost's Migration Assistant. It reads one region and imports mailing list members as contacts in static segments, each domain's bounces, complaints and unsubscribes, domain templates as drafts, domain-level webhooks switched off, and your domains with new DKIM keys. Routes, account-level templates and webhooks, logs, and validation stay at Mailgun, and EU domains need a check if the key also works in the US.

**Medium migration.** Mailing list members, every domain's bounces, complaints and unsubscribes, and domain templates import; routes, account-level templates and account-level webhooks are rebuilt by hand.

## What comes with you

| Data | Result | What happens |
| --- | --- | --- |
| Contacts | Imports | Mailgun keeps addresses only as mailing list members, so the importer reads every list's members with their name and `vars` (as custom fields). An address on several lists imports once. |
| Suppressions (bounces and complaints) | Imports | Bounces and complaints from every domain in the region import with their reason, merged into one workspace list. |
| Mailing lists | Imports | Each list becomes a static segment named after the list (or its address), with its members. |
| Unsubscribes and consent | Partly imports | Each domain's unsubscribes import as unsubscribe suppressions, tag-level ones included, and become a full marketing opt-out here. A member unsubscribed from any one list imports unsubscribed from all marketing. |
| Templates | Partly imports | Domain templates import the active version's HTML as drafts in code mode. Account-level templates and subjects are not read, and a template whose content fails to load arrives empty without a warning. |
| Sending domains | Partly imports | Every domain in the region the importer reads is recreated as verifying with a new DKIM key. |
| Webhooks | Partly imports | Domain-level webhooks are merged per URL and recreated switched off with a new signing secret. Account-level webhooks are not read. |
| Topics | Rebuild | Mailgun has no topics. Create them here if you want per-category opt-outs on campaigns. |
| API keys and SMTP credentials | Rebuild | Your Private API key and per-domain SMTP credentials stay at Mailgun. Unitpost SMTP logs in with an API key. |
| Routes | Rebuild | Not read. Unitpost receives mail per verified domain, stores it and fires `email.received`; there is no forwarding, so a `forward()` route becomes a webhook handler. |
| Send history and analytics | Stays behind | Logs, events and stats stay at Mailgun. |
| Automations and scheduled sends | Stays behind | Messages scheduled with `o:deliverytime` stay queued at Mailgun. Unitpost automations are not launched yet. |
| Email validation | Stays behind | Unitpost has no address validation product. Keep Mailgun's if your signup flow depends on it. |

## Before you start

- Your account Private API key (Settings → API Security). A domain sending key cannot read lists or suppressions.
- If you have EU domains: Mailgun API keys are account-wide, and the importer keeps the US API whenever the key works there. EU lists and suppressions live only in the EU region, so plan to check them by hand (step 2).
- A Unitpost API key with `emails:send` for your app, created in [API keys](/dashboard/api-keys).
- A second Unitpost key for the migration steps, with `domains:write`, `suppressions:write`, `templates:write` and `webhooks:manage`, saved as `UNITPOST_MIGRATION_KEY`. Keep it out of your app and revoke it once the move is done.
- Access to your DNS host, including the MX records if Mailgun receives mail for you.

## The cut-over plan

### 1. Run the Migration Assistant

Paste the key in [Settings → Migration](/dashboard/settings/migration). The account label in the preview names the region it read (`Mailgun US` or `Mailgun EU`). Suppressions are written before contacts, so unsubscribed members land unsubscribed.

### 2. Bring EU suppressions over by hand if needed

If the preview says US and you also send from EU domains, read each EU domain's `/v3/{domain}/bounces`, `/complaints` and `/unsubscribes` on `api.eu.mailgun.net` and add the addresses with `POST /api/v1/suppressions` (`emails`, up to 1,000 per call) before the first send.

### 3. Verify your domains next to Mailgun

Open [Domains](/dashboard/email/domains) and publish the records shown: MX and SPF on `unitpost.<your domain>` and a DKIM TXT under `_domainkey`. Mailgun's records keep working, so the domain is verified on both. Keep your single `_dmarc` record.

### 4. Rebuild routes as receiving

If Mailgun receives for a domain, its MX points at `mxa.mailgun.org` and `mxb.mailgun.org` (EU: `mxa.eu.mailgun.org`, `mxb.eu.mailgun.org`). Turn receiving on with `PATCH /api/v1/email/domains/{id}` (`capabilities.receiving` set to `enabled`), subscribe an endpoint to `email.received`, then swap the MX record.

### 5. Port templates

Mailgun templates use Handlebars with `if`, `unless`, `each`, `with` and `equal`. Unitpost fills `{{name}}` placeholders only, so render those blocks in code, and replace `%unsubscribe_url%` with `{{unsubscribe_url}}`. Set receipts to `transactional` (imports land as marketing, which adds a footer) and publish with `PATCH /api/v1/email/templates/{id}`.

### 6. Switch to JSON and a Bearer key

The domain leaves the path, form fields become JSON, `o:tag` becomes `tags`, `template` with `t:variables` becomes `template: { id, variables }`, and `o:deliverytime` becomes ISO 8601 `scheduled_at` (up to 90 days ahead, against Mailgun's plan-dependent window of up to 7 days).

```ts
// Before: Mailgun (form fields, HTTP Basic auth)
const form = new FormData();
form.append("from", "Acme <hi@mg.acme.com>");
form.append("to", "user@example.com");
form.append("subject", "Your receipt");
form.append("html", receiptHtml);
await fetch("https://api.mailgun.net/v3/mg.acme.com/messages", {
  method: "POST",
  headers: {
    Authorization: "Basic " + Buffer.from("api:" + process.env.MAILGUN_API_KEY).toString("base64"),
  },
  body: form,
});

// After: Unitpost (JSON, Bearer key, no domain in the path)
import { Unitpost } from "unitpost";
const unitpost = new Unitpost(process.env.UNITPOST_API_KEY);
const { data, error } = await unitpost.email.send({
  from: "Acme <hi@mg.acme.com>",
  to: "user@example.com",
  subject: "Your receipt",
  html: receiptHtml,
});
```

### 7. Replace the webhook verifier

Mailgun signs `timestamp` and `token` in the body with one account-wide signing key. Unitpost signs the raw body with a per-endpoint `whsec_` secret in the svix headers (`svix-id`, `svix-timestamp`, `svix-signature`). Rotate the secret in [Webhooks](/dashboard/webhooks) to see it, deploy the verifier, then set `status` to `enabled`; a switched-off endpoint does not queue events.

### 8. Send tests

Send each template with `POST /api/v1/email/templates/{id}/test-send`, mail a test message to a receiving domain, and fire `POST /api/v1/webhooks/{id}/test` at each endpoint.

### 9. Move traffic in stages

Move one message type first, watch `email.bounced` and `email.complained`, then the rest. Mailgun stays verified, so rolling back is a config change.

### 10. Cancel Mailgun last

Wait for messages scheduled at Mailgun to go out, finish the checks below, then close the account.

## Same ideas, new names

| Mailgun | Unitpost | Note |
| --- | --- | --- |
| Mailing list | Segment (static) |  |
| Member `vars` | Contact fields |  |
| Route | Receiving + `email.received` webhook |  |
| Tag (`o:tag`) | Tag (`tags: [{ name, value }]`) |  |
| Webhook signing key (account-wide) | Signing secret per endpoint |  |
| Private API key | API key with scopes |  |
| SMTP credentials (per domain) | API key as SMTP password |  |
| Region (US or EU) | One region (US) | Unitpost has no EU data residency. |

## API calls

| Mailgun API | Unitpost API | Note |
| --- | --- | --- |
| POST /v3/{domain}/messages | POST /api/v1/email | Form fields and Basic auth become JSON and a Bearer key. |
| `recipient-variables` batch (up to 1,000) | POST /api/v1/email/batch | Up to 100 messages per call. |
| `template` + `t:variables` | `template: { id, variables }` |  |
| `o:deliverytime` | `scheduled_at` |  |
| `o:tag` | `tags` |  |
| GET /v3/{domain}/bounces, /complaints | GET /api/v1/suppressions?reason=bounce or complaint |  |
| GET /v3/{domain}/unsubscribes | GET /api/v1/suppressions?reason=unsubscribe |  |
| POST /v3/lists/{address}/members | POST /api/v1/segments/{id}/contacts |  |
| GET /v4/domains | GET /api/v1/email/domains |  |
| GET /v3/domains/{domain}/webhooks | GET /api/v1/webhooks |  |

## SMTP settings

| Mailgun SMTP | Unitpost SMTP | Note |
| --- | --- | --- |
| smtp.mailgun.org (EU: smtp.eu.mailgun.org) | smtp.unitpost.com |  |
| Username: the domain's SMTP login | Username `unitpost` |  |
| Password: that login's password | Password: Unitpost API key with `emails:send` |  |
| 25, 587, 2525 (STARTTLS) | 587, 2587, 25 (STARTTLS) | TLS 1.2 or later before login. |
| 465 (TLS) | 465, 2465 (implicit TLS) |  |
| `X-Mailgun-Template-Variables` header | No SMTP equivalent | Render the body before sending, or use the API. |

## Webhook events

| Mailgun event | Unitpost event | Note |
| --- | --- | --- |
| accepted | email.sent |  |
| delivered | email.delivered |  |
| temporary_fail | email.delivery_delayed |  |
| permanent_fail | email.bounced |  |
| complained | email.complained |  |
| opened | email.opened |  |
| clicked | email.clicked |  |
| unsubscribed | contact.updated | The importer's mapping. |
| Route `forward()` to a URL | email.received | Not imported. |

## Before you cancel Mailgun

- Pull bounces, complaints and unsubscribes for every domain in both regions one last time and add new ones here.
- Export the logs and stats you want to keep; none of them move.
- Move MX records off `mxa` and `mxb` before routes stop working.
- Delete Mailgun's DKIM, SPF and tracking records once no mail goes through Mailgun. Keep `_dmarc` and Unitpost's records.
- Delete Mailgun webhooks at domain and account level, and its API keys and SMTP credentials.

Facts about Mailgun checked 2026-11-02.

## FAQ

### My Mailgun domains are in the EU. Can Unitpost keep data in the EU?

No. Unitpost runs in the US only today, while Mailgun's EU region keeps message data in the EU. If EU residency is a hard requirement, stay on Mailgun's EU region.

### Does Unitpost validate email addresses like Mailgun?

No. Unitpost checks syntax at the API and suppresses addresses that hard-bounce or complain, but has no list validation product. Keep Mailgun's validation if you rely on it.

### Why did a contact who left one list arrive unsubscribed from everything?

Unitpost has one marketing opt-out per contact, so an unsubscribe from any Mailgun list is kept as a full opt-out. Use topics here if you want per-category choices.

### Do my routes come over?

No. Turn receiving on for the domain, subscribe a webhook to `email.received`, and move the MX record; stored mail and attachments are read through the API.

## Related

- [Migrate from Brevo to Unitpost](https://www.unitpost.com/migration/brevo): Leave Brevo in order: import contacts, lists, blocked contacts and templates, rewrite params variables, re-verify DNS, re-record SMS consent, swap the call.
- [Migrate from Loops to Unitpost](https://www.unitpost.com/migration/loops): Leave Loops in order: export the audience CSV, turn subscribed into an unsubscribed column, copy suppressions, import lists as topics, rebuild emails.
- [Migrate from Resend to Unitpost](https://www.unitpost.com/migration/resend): Leave Resend in order: copy its suppression list, import contacts, topics and templates, re-verify DNS, swap the SDK and re-key svix webhooks.
