Migrate from Mailgun
The provider-specific half of the migration guide: how Mailgun's POST /v3/{domain}/messages parameters, suppression lists, and webhook events map onto Bird. Do the steps in the main guide in order — this page is the lookup table for steps 1, 3, and 4.
Map the send call
Mailgun's form-encoded parameter prefixes (o: options, v: variables, h: headers) all become first-class JSON fields on POST /v1/email/messages:
| What it does | Mailgun | Bird |
|---|---|---|
| Sender | from | from |
| Recipients | to / cc / bcc | to / cc / bcc (arrays, max 50 each) |
| Subject | subject | subject |
| Body | html / text | html / text (at least one) |
| Reply-to | h:Reply-To | reply_to (array, 1–25) |
| Custom headers | h:X-* | headers (string → string object) |
| Filterable labels | o:tag | tags — {name, value} pairs, max 20 |
| Round-trip context | v:* / X-Mailgun-Variables | metadata — arbitrary JSON, max 2 KB |
| Open/click tracking | o:tracking-opens / o:tracking-clicks | track_opens / track_clicks (default true) |
| Category | — | category: transactional (default) or marketing |
Porting notes:
- The request becomes JSON. Mailgun accepts multipart form data; Bird takes a JSON body with Content-Type: application/json — usually the biggest mechanical change in the port.
- v: variables were echoed in events; Bird metadata is not. Webhook payloads carry email_id/recipient_id instead — fetch the message by email_id when a handler needs your variables back.
- Recipient variables (recipient-variables batch sending) have no direct equivalent: Bird's batch endpoint takes fully-rendered per-recipient entries rather than a template plus substitutions.
- o:deliverytime → scheduled_at is reserved and returns 422 unsupported_feature today, as do attachments — plan around both before cutover. The full reserved list is in Sending email.
Export suppressions
Mailgun keeps three per-domain lists; export each and run them through the import loop:
- GET /v3/{domain}/bounces
- GET /v3/{domain}/complaints
- GET /v3/{domain}/unsubscribes
Repeat per sending domain — Mailgun's lists are domain-scoped, while Bird suppressions are workspace-scoped, so the union of your domains' lists is what you import.
Translate webhook events
Mailgun signals temporary vs permanent failure with one failed event plus a severity field; Bird splits them:
| Outcome | Mailgun | Bird |
|---|---|---|
| Accepted/processed | accepted | email.accepted → email.processed |
| Delivered | delivered | email.delivered |
| Temporary failure | failed (temporary) | email.deferred |
| Permanent bounce | failed (permanent) | email.bounced / email.out_of_band_bounce |
| Spam complaint | complained | email.complained |
| Blocked/suppressed | — | email.rejected |
| Open | opened | email.opened |
| Click | clicked | email.clicked |
| Unsubscribe | unsubscribed | email.list_unsubscribed |
email.rejected has no Mailgun counterpart: Bird reports suppressed recipients visibly (status rejected, rejection_reason: recipient_suppressed) instead of silently skipping them — add a handler for it rather than treating it as a bounce.
Verification also changes: Mailgun signs with an HMAC over timestamp + token inside the payload's signature object, while Bird signs per the Standard Webhooks specification — headers, not payload fields. Swap your verification code for the recipe in Webhooks & events.
Cut over
Work through domains & DNS and the sandbox smoke test in the main guide — both are provider-independent.