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. For a new integration rather than a migration, use receive email in Node.js, receive email in Python, or the shorter inbound email guide. The inbound email API page compares a parse webhook with 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 | 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, filters, and 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
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 page, checked October 27, 2026. Samva's get email details reference 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:
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
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 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 and receive email in 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
| 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 |
| 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, and longer if old-route arrivals or unresolved work remain.
Cut over one hostname with a rollback record
- 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.
- Prove a test hostname. Configure a verified Samva domain and endpoint with configure receiving. Use a throwaway subdomain before the production one. Receiving is catch-all for the domain; enforce your accepted-address rules after verification.
- 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.
- 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.
- 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.
- 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.
- 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 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
| 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.