Migration from Mailgun

Move transactional sends from Mailgun to Groundmail: receipts, password resets, alerts and invites go out over POST /emails with a JSON body instead of form fields.


Switch in 5 minutes (Beta)

Import your Mailgun setup in one step: paste your Mailgun private API key in Import (or run groundmail migrate mailgun) and Groundmail brings over your suppressions (bounces, complaints, unsubscribes), templates (Handlebars variables converted), sending domains (with the DNS records to publish, one click on Cloudflare, Vercel and GoDaddy) and webhooks (events mapped, created disabled until your handler is ready). EU accounts pick the EU region. The key is used for the import only and never stored.

You get a summary of what came across and what needs attention, the send-code change for your stack, and a prompt for your coding agent. Details: Import from SendGrid, Postmark or Mailgun.

export MAILGUN_API_KEY=…
groundmail migrate mailgun --dry-run   # preview
groundmail migrate mailgun             # import (add --region eu for EU accounts)

After the import, the Import page keeps a switch checklist for your workspace: verify your domains, send a test, point your app at Groundmail, then a cutover check on the last 24 hours of sending. Each domain gets the exact DNS changes against what it publishes today: Groundmail's include goes into your existing SPF record (never a second SPF record), your DMARC record stays, and the rest are new records.

Keep your Mailgun DNS records while you switch. Leave the DKIM TXT record (such as smtp._domainkey or k1._domainkey) and the email. tracking CNAME in place: they use different names from Groundmail's, so mail from both keeps passing DKIM while your app moves over. Remove them, and include:mailgun.org from SPF, a few days after the cutover. The import report (what came across, what was skipped and why) downloads as CSV from the Import page or with groundmail migrate report <id> --csv.

Sending a lot of mail? We'll do it for you on the Import page asks our team to run the switch with you.

The rest of this page is the same move by hand, field by field.


Quick mapping

Mailgun Groundmail
Private API key, HTTP Basic api:<key> gm_… API key (Authorization: Bearer)
https://api.mailgun.net/v3/{domain}/messages https://api.groundmail.dev/emails
Form fields (from, to, subject, html, text) JSON fields with the same names
template + h:X-Mailgun-Variables template: { id: "<alias>", variables: { … } }
o:tag tags
h:Reply-To reply_to
Domain in the URL Domain of the from address (must be verified)
Webhooks per domain and event Webhooks per URL, any set of events
Bounces / complaints / unsubscribes per domain One suppression list per workspace

Minimal send

Mailgun:

curl -X POST https://api.mailgun.net/v3/mg.yourdomain.com/messages \
  --user "api:$MAILGUN_API_KEY" \
  -F from='you@yourdomain.com' \
  -F to='user@example.com' \
  -F subject='Hello' \
  -F html='<p>It works.</p>'

Groundmail:

curl -X POST https://api.groundmail.dev/emails \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "you@yourdomain.com",
    "to": "user@example.com",
    "subject": "Hello",
    "html": "<p>It works.</p>"
  }'

Node.js

Mailgun:

import Mailgun from 'mailgun.js'

const mg = new Mailgun(FormData).client({ username: 'api', key: process.env.MAILGUN_API_KEY! })
await mg.messages.create('mg.yourdomain.com', {
  from: 'you@yourdomain.com',
  to: 'user@example.com',
  template: 'welcome',
  'h:X-Mailgun-Variables': JSON.stringify({ name: 'Ada' }),
})

Groundmail:

import { Groundmail } from 'groundmail'

const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
await groundmail.emails.send({
  from: 'you@yourdomain.com',
  to: 'user@example.com',
  template: { id: 'welcome', variables: { name: 'Ada' } },
})

Webhooks

Mailgun posts one event per request with a signature object. Groundmail sends one signed JSON object per delivery with a Groundmail-Signature header (HMAC-SHA256); verify it with verifyWebhookSignature from the groundmail package.

Mailgun event Groundmail
accepted email.sent
delivered email.delivered
temporary_fail email.delivery_delayed
permanent_fail email.bounced and email.failed (bounce_type says hard or block)
complained email.complained
opened email.opened
clicked email.clicked
unsubscribed contact.unsubscribed

Use Send test event on the dashboard webhooks page (or POST /emails/test) to check your handler before cutover.


Checklist

  1. Create a Groundmail API key (gm_…)
  2. Import from Mailgun, or add and verify your sending domain by hand (SPF, DKIM, return path)
  3. Swap the send call and map fields per the table above
  4. Update your webhook handler, then enable the webhooks
  5. Send from the sandbox, then production after the domain verifies

Compare

Marketing overview: Mailgun alternative.