Skip to content
Mailgun

Migrate Mailgun Inbound Routes to a Signed Webhook

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

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 routeSamvaMigration decision
match_recipient("support@example.com") or a regexReceiving is on for the whole domain. The webhook lists the routed addresses in data.toEmailsMove the match into your handler. Check toEmails against your accepted-address rules before assigning work.
match_header(...)No header-based route filterFetch 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 setupMail 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 endpointIdOne endpoint per domain. Fan out inside your app.
forward("team@example.org")A forwarding rulesourcePattern 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.messageIdNo notify callback to rebuild. The webhook is the notification.
stop() and route priorityForwarding-rule priorityA 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 fieldSamva destinationMigration decision
recipientdata.to, and every routed address in data.toEmailsMailgun's value is the SMTP recipient. Recheck alias routing.
sender, fromdata.from; fromEmail and fromName in the email detailsMailgun's sender is the SMTP envelope sender and can differ from from. The details carry the header sender only.
subjectdata.subjectOptional. Can be absent.
body-plain, body-htmldata.body, or downloadUrls.text and downloadUrls.htmldata.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-signatureNo equivalent claimedSamva returns the body as the sender wrote it. Keep your own trimming.
message-headersmessageIdHeader, inReplyTo, references in the email detailsUse them to reconcile existing threads. Fetch the raw MIME for other headers.
body-mime on a mime URLdownloadUrls.rawMimeDownload with a fresh signed link.
attachment-count, attachment-N, content-id-maphasAttachments, attachmentCount, and an attachments array with id, filename, content type, size, isInline, and contentIdSamva does not upload file parts to your endpoint. Download each by id.
timestamp, token, signaturewebhook-id, webhook-timestamp, webhook-signature headersA 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 URLSamva
Success200Any 2xx
Permanent refusal406 is not retriedNo such status. Answer 2xx and discard in code
FailureRetried for a total of 8 hours at widening intervalsUp to eight automatic attempts on the published schedule
Ordering and repeatsNot covered on the receiving pageAt 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

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

CaseRequired result
Valid and tampered webhookThe valid event reaches durable storage; a changed body or missing signature creates no work.
Duplicate deliveryOne job, even with a fresh retry signature.
Storage fails, then recoversThe first request is non-2xx; a retry persists and processes it.
Recipient outside your rulesRecorded or discarded by your code with a 2xx, never with a 406-style refusal.
Plain text, HTML, and UnicodeIntended content survives; an absent format stays absent.
File attachment and inline imageBytes, filename, content type, and content ID are preserved.
Reply to a pre-migration threadThe mapping to your existing record is explicit, and a new Samva conversation does not silently create a duplicate.
Out-of-office replyRecorded without sending another automated reply (isAutoReply).
MX rollback with work in flightBoth 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.

Frequently Asked Questions

Related Resources

Send

Ship your first email today.

Transactional and product email through one typed API, with signed events, conversation threading, and deliverability handled.