# Migrate Mailgun Inbound Routes to a Signed Webhook
URL: https://samva.dev/guides/mailgun-inbound-migration
> Move Mailgun Routes that forward to a URL onto Samva: map filters, fields, signatures, and retries, then cut over one hostname's MX with a rollback record.
- Published: 2026-10-27



Move one receiving hostname first, such as `replies.example.com`. Keep your Mailgun route and its handler running while you prove the Samva path, then replace that hostname's MX records. Outbound sending can stay on Mailgun throughout.

This guide is for developers whose Mailgun Routes use `forward()` to post email to a URL. For the product decision, start with [Samva vs Mailgun](/compare/mailgun). For a new integration rather than a migration, use [receive email in Node.js](/guides/receive-email-nodejs), [receive email in Python](/guides/receive-email-python), or the shorter [inbound email guide](/guides/inbound-email-parsing). The [inbound email API](/features/inbound-email) page compares a parse webhook with Samva.

## Map the route to Samva [#map-the-route-to-samva]

A Mailgun route is a filter plus actions. Samva has one place where the same decisions live:

| Mailgun route                                       | Samva                                                                                           | Migration decision                                                                                                                   |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `match_recipient("support@example.com")` or a regex | Receiving is on for the whole domain. The webhook lists the routed addresses in `data.toEmails` | Move the match into your handler. Check `toEmails` against your accepted-address rules before assigning work.                        |
| `match_header(...)`                                 | No header-based route filter                                                                    | Fetch the email details and test the header in code. Only the threading headers are parsed fields; read the raw MIME for the rest.   |
| `catch_all()`                                       | `catchAll: true` on the domain's receiving setup                                                | Mail that matches neither a rule nor an endpoint is rejected as an unknown recipient.                                                |
| `forward("https://app.example.com/inbound")`        | A webhook endpoint subscribed to `message.received`, set as the domain's `endpointId`           | One endpoint per domain. Fan out inside your app.                                                                                    |
| `forward("team@example.org")`                       | A [forwarding rule](/docs/channels/email/forward-inbound-email)                                 | `sourcePattern` matches the recipient with a `*` wildcard, not a regular expression. Highest priority wins; specificity breaks ties. |
| `store()`                                           | Samva stores each received message; read it by `data.messageId`                                 | No `notify` callback to rebuild. The webhook is the notification.                                                                    |
| `stop()` and route priority                         | Forwarding-rule priority                                                                        | A webhook endpoint is a single destination, so there is nothing to stop.                                                             |

The Mailgun route behavior follows its [routes](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/routes), [filters](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/route-filters), and [actions](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/route-actions) pages, checked October 27, 2026. Export your routes before you start and note which ones post to a URL, which forward to an address, and which only store.

## Map the fields your handler reads [#map-the-fields-your-handler-reads]

Mailgun posts form fields, or JSON on a URL ending in `json`. Samva posts a JSON event with `type`, `timestamp`, and `data`, and the event signals that a message arrived; fetch the email details when you need more.

| Mailgun field                                          | Samva destination                                                                                                                  | Migration decision                                                                                                                              |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `recipient`                                            | `data.to`, and every routed address in `data.toEmails`                                                                             | Mailgun's value is the SMTP recipient. Recheck alias routing.                                                                                   |
| `sender`, `from`                                       | `data.from`; `fromEmail` and `fromName` in the email details                                                                       | Mailgun's `sender` is the SMTP envelope sender and can differ from `from`. The details carry the header sender only.                            |
| `subject`                                              | `data.subject`                                                                                                                     | Optional. Can be absent.                                                                                                                        |
| `body-plain`, `body-html`                              | `data.body`, or `downloadUrls.text` and `downloadUrls.html`                                                                        | `data.body` is the HTML when there is one, otherwise the text. Fetch the explicit format when the distinction matters; either link can be null. |
| `stripped-text`, `stripped-html`, `stripped-signature` | No equivalent claimed                                                                                                              | Samva returns the body as the sender wrote it. Keep your own trimming.                                                                          |
| `message-headers`                                      | `messageIdHeader`, `inReplyTo`, `references` in the email details                                                                  | Use them to reconcile existing threads. Fetch the raw MIME for other headers.                                                                   |
| `body-mime` on a `mime` URL                            | `downloadUrls.rawMime`                                                                                                             | Download with a fresh signed link.                                                                                                              |
| `attachment-count`, `attachment-N`, `content-id-map`   | `hasAttachments`, `attachmentCount`, and an `attachments` array with id, filename, content type, size, `isInline`, and `contentId` | Samva does not upload file parts to your endpoint. Download each by id.                                                                         |
| `timestamp`, `token`, `signature`                      | `webhook-id`, `webhook-timestamp`, `webhook-signature` headers                                                                     | A different scheme. Keep each route's verifier.                                                                                                 |

The Mailgun fields follow its [receiving messages via HTTP](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/receive-http) page, checked October 27, 2026. Samva's [get email details reference](/docs/api-reference/messages/getEmail) shows the complete response.

If a large downstream function already reads Mailgun's field names, an adapter keeps it unchanged while you migrate. It takes the verified event and the email details and returns the fields that function reads:

```ts title="mailgun-shape.ts"
import { createClient } from "samva";
import type { MessageReceivedPayload } from "samva/webhooks";

const samva = createClient({
  apiKey: process.env.SAMVA_API_KEY!,
  baseUrl: process.env.SAMVA_API_URL ?? "https://api.samva.dev",
});

export async function toMailgunShape(event: MessageReceivedPayload) {
  const email = await samva.messages.getEmail({ id: event.messageId, includeDownloadUrls: true });

  const textUrl = email.downloadUrls?.text;
  const text = textUrl ? await (await fetch(textUrl)).text() : "";

  return {
    recipient: event.to,
    sender: email.fromEmail, // header sender; Samva exposes no SMTP envelope sender
    from: email.fromName ? `${email.fromName} <${email.fromEmail}>` : email.fromEmail,
    subject: email.subject ?? "",
    "body-plain": text,
    "attachment-count": email.attachmentCount,
    "message-headers": [
      ["Message-Id", email.messageIdHeader],
      ["In-Reply-To", email.inReplyTo],
      ["References", email.references?.join(" ")],
    ].filter(([, value]) => value),
  };
}
```

Delete the adapter when the downstream code reads Samva's fields directly. It leaves out `stripped-text`, `body-html`, and attachment bytes on purpose, because each has a different source or no equivalent.

## Verify the webhook and accept it once [#verify-the-webhook-and-accept-it-once]

Mailgun signs form posts with a `timestamp`, `token`, and `signature` field and signs JSON posts through `X-Mailgun-*` headers. Samva signs with the [Standard Webhooks](https://www.standardwebhooks.com/) headers `webhook-id`, `webhook-timestamp`, and `webhook-signature`, keyed by the endpoint's own secret. Run them on separate routes during the transition, such as `/webhooks/mailgun` and `/webhooks/samva`. Do not reuse the Mailgun signing key or run its verifier on the Samva route.

Register an endpoint subscribed to `message.received`, store its signing secret, and verify the raw request before reading it. [Receive email in Node.js](/guides/receive-email-nodejs) and [receive email in Python](/guides/receive-email-python) give a complete handler with deduplication on `webhook-id`. Return `2xx` only after the event is durably accepted, and do slower work in a worker.

## Retries differ in one way that matters [#retries-differ-in-one-way-that-matters]

|                      | Mailgun forward to a URL                             | Samva                                                                                                    |
| -------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Success              | `200`                                                | Any `2xx`                                                                                                |
| Permanent refusal    | `406` is not retried                                 | No such status. Answer `2xx` and discard in code                                                         |
| Failure              | Retried for a total of 8 hours at widening intervals | Up to eight automatic attempts on the [published schedule](/docs/platform/webhook-events#retry-schedule) |
| Ordering and repeats | Not covered on the receiving page                    | At least once and unordered. Deduplicate on `webhook-id`                                                 |

A handler that returned `406` to drop unwanted mail must return `2xx` on Samva, or the same email returns on every retry. Keep the Mailgun handler accepting queued retries for at least eight hours after the last delivery it receives, per [Mailgun's retry behavior](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/receive-http), and longer if old-route arrivals or unresolved work remain.

## Cut over one hostname with a rollback record [#cut-over-one-hostname-with-a-rollback-record]

1. **Inventory the route.** Save the receiving hostname, every route expression and action, priorities, and the exact MX values, priorities, and TTL. If a route forwards to an address, note that Mailgun asks you to disable click tracking, open tracking, and unsubscribes for the domain so it does not alter forwarded content.
2. **Prove a test hostname.** Configure a verified Samva domain and endpoint with [configure receiving](/guides/inbound-email-parsing#configure-receiving). Use a throwaway subdomain before the production one. Receiving is catch-all for the domain; enforce your accepted-address rules after verification.
3. **Exercise the acceptance cases below** with controlled test mail and signed fixtures. A webhook test alone does not prove SMTP routing, MIME parsing, or attachment availability.
4. **Prepare DNS.** Lower the receiving hostname's TTL ahead of time and wait out the old TTL. Confirm Samva receiving is enabled for the exact hostname and copy the MX target and priority Samva shows for it; do not guess a hostname.
5. **Replace that hostname's MX records.** MX priority is failover preference, not a percentage rollout and not a way to copy every email to both services. If you are moving only a reply subdomain, leave your primary mailbox domain alone.
6. **Observe and keep both handlers.** Check authoritative and public-resolver MX answers, then reconcile controlled test mail through verification, durable storage, body and attachment retrieval, and your own record. DNS caches and sender queues can outlast a TTL.
7. **Roll back on missing or misrouted mail.** Restore the saved MX records and keep both handlers and workers running. DNS rollback affects future routing; it does not move mail either provider already accepted. Remove the Mailgun route only after the queues, your records, and the observation window agree.

Samva's [receiving status](/docs/api-reference/email/getReceivingStatus) lists what is left before mail can route to a domain, and `samva email receiving status <domain-id>` reads the same from the CLI.

## Prove these cases before moving customer replies [#prove-these-cases-before-moving-customer-replies]

| Case                             | Required result                                                                                                     |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Valid and tampered webhook       | The valid event reaches durable storage; a changed body or missing signature creates no work.                       |
| Duplicate delivery               | One job, even with a fresh retry signature.                                                                         |
| Storage fails, then recovers     | The first request is non-2xx; a retry persists and processes it.                                                    |
| Recipient outside your rules     | Recorded or discarded by your code with a `2xx`, never with a `406`-style refusal.                                  |
| Plain text, HTML, and Unicode    | Intended content survives; an absent format stays absent.                                                           |
| File attachment and inline image | Bytes, filename, content type, and content ID are preserved.                                                        |
| Reply to a pre-migration thread  | The mapping to your existing record is explicit, and a new Samva conversation does not silently create a duplicate. |
| Out-of-office reply              | Recorded without sending another automated reply (`isAutoReply`).                                                   |
| MX rollback with work in flight  | Both queues drain and no business effect repeats.                                                                   |

Keep the Mailgun route until it has proved the behavior your application needs, and use this table as your acceptance record.

## FAQ

### Can I keep Mailgun for outbound email?

Yes. Inbound mail follows the MX records of the receiving hostname, so you can move a dedicated receiving subdomain to Samva and leave Mailgun sending, and its sending DNS records, untouched.

### What replaces a Mailgun route filter like match_recipient?

Samva receives mail for a whole domain and posts one webhook per email, with the routed addresses in toEmails. Matching on the recipient becomes a check in your handler. Forwarding to an email address uses a forwarding rule, whose source pattern supports a * wildcard instead of a regular expression.

### Does Samva have an equivalent of Mailgun's stripped-text field?

No claim is made for one. Samva returns the body as the sender wrote it, including any history their mail client quoted, so keep your own reply-trimming step if your code relied on stripped-text.

### How do I reject an inbound email permanently, like Mailgun's 406?

Samva treats any non-2xx answer as a failed attempt and retries it, so there is no status that means do not retry. To discard an email, answer 2xx and ignore it in your own code.

### Does the migration import stored Mailgun messages?

No. Mailgun stores a message for up to three days, so retrieve anything you need before you remove the route. Samva starts with the mail it receives after the MX change.
## Related

- [Migrate SendGrid Inbound Parse](/guides/sendgrid-inbound-migration): The same cutover for Inbound Parse, with its own payload map.
- [Samva vs Mailgun](/compare/mailgun): Compare the products before choosing a migration.
- [Inbound email API](/features/inbound-email): What the parsed webhook contains, compared with a parse webhook.
- [Receive, thread, and reply](/guides/inbound-email-parsing): Configure receiving and send a reply on the same conversation.
- [Receive email in Node.js](/guides/receive-email-nodejs): A signed webhook endpoint with the TypeScript SDK.
- [Receive email in Python](/guides/receive-email-python): The same endpoint with the standard library and Flask.
- [Forward inbound email](/docs/channels/email/forward-inbound-email): Forwarding rules, priority, and which rule wins.
