Migrate from Mailgun to Unitpost

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

MailgunUnitpost
ContactsImports
Suppressions (bounces and complaints)Imports
Unsubscribes and consentPartly imports
TopicsRebuild

What comes with you

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.

DataResultWhat happens
ContactsImportsMailgun 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)ImportsBounces and complaints from every domain in the region import with their reason, merged into one workspace list.
Mailing listsImportsEach list becomes a static segment named after the list (or its address), with its members.
Unsubscribes and consentPartly importsEach 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.
TemplatesPartly importsDomain 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 domainsPartly importsEvery domain in the region the importer reads is recreated as verifying with a new DKIM key.
WebhooksPartly importsDomain-level webhooks are merged per URL and recreated switched off with a new signing secret. Account-level webhooks are not read.
TopicsRebuildMailgun has no topics. Create them here if you want per-category opt-outs on campaigns.
API keys and SMTP credentialsRebuildYour Private API key and per-domain SMTP credentials stay at Mailgun. Unitpost SMTP logs in with an API key.
RoutesRebuildNot 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 analyticsStays behindLogs, events and stats stay at Mailgun.
Automations and scheduled sendsStays behindMessages scheduled with o:deliverytime stay queued at Mailgun. Unitpost automations are not launched yet.
Email validationStays behindUnitpost 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.
  • 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

Mailgun keeps sending until the last step, so there is no gap in delivery.

  1. Run the Migration Assistant

    Paste the key in 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 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).

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

Name changes

Same ideas, new names

MailgunUnitpostNote
Mailing listSegment (static)
Member varsContact fields
RouteReceiving + email.received webhook
Tag (o:tag)Tag (tags: [{ name, value }])
Webhook signing key (account-wide)Signing secret per endpoint
Private API keyAPI 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 APIUnitpost APINote
POST /v3/{domain}/messagesPOST /api/v1/emailForm fields and Basic auth become JSON and a Bearer key.
recipient-variables batch (up to 1,000)POST /api/v1/email/batchUp to 100 messages per call.
template + t:variablestemplate: { id, variables }
o:deliverytimescheduled_at
o:tagtags
GET /v3/{domain}/bounces, /complaintsGET /api/v1/suppressions?reason=bounce or complaint
GET /v3/{domain}/unsubscribesGET /api/v1/suppressions?reason=unsubscribe
POST /v3/lists/{address}/membersPOST /api/v1/segments/{id}/contacts
GET /v4/domainsGET /api/v1/email/domains
GET /v3/domains/{domain}/webhooksGET /api/v1/webhooks

SMTP settings

Mailgun SMTPUnitpost SMTPNote
smtp.mailgun.org (EU: smtp.eu.mailgun.org)smtp.unitpost.com
Username: the domain's SMTP loginUsername unitpost
Password: that login's passwordPassword: 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 headerNo SMTP equivalentRender the body before sending, or use the API.

Webhook events

Mailgun eventUnitpost eventNote
acceptedemail.sent
deliveredemail.delivered
temporary_failemail.delivery_delayed
permanent_failemail.bounced
complainedemail.complained
openedemail.opened
clickedemail.clicked
unsubscribedcontact.updatedThe importer's mapping.
Route forward() to a URLemail.receivedNot 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. Providers change their APIs and plans; check their docs before you rely on a detail.

Questions about leaving Mailgun

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.

Leaving Mailgun?

Preview the migration on the free tier

Verify a domain, paste a read-only key, and see what would come across before anything is written.