{"id":555,"date":"2026-11-13T01:02:19","date_gmt":"2026-11-13T06:32:19","guid":{"rendered":"https:\/\/photonconsole.com\/blog\/?p=555"},"modified":"2026-11-13T17:03:13","modified_gmt":"2026-11-13T22:33:13","slug":"migrating-from-mailgun-templates-webhooks-and-suppression-data","status":"publish","type":"post","link":"https:\/\/photonconsole.com\/blog\/migrating-from-mailgun-templates-webhooks-and-suppression-data\/","title":{"rendered":"Migrating from Mailgun: Templates, Webhooks and Suppression Data"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">The sending migration goes well. Four environment variables, a ramp over a few days, delivery metrics clean. You decommission the Mailgun account on a Friday.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On Monday someone asks why nobody has replied to any support ticket since Thursday.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Mailgun is the one major provider that is genuinely bidirectional. It sends your mail and it <em>receives<\/em> mail, matching it against regex rules and posting it into your application. Teams migrate the half they think about and forget the half that was working silently for two years.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Quick Answer: What Moves and What Gets Rebuilt<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Asset<\/th><th>Moves or rebuilds<\/th><th>Effort<\/th><\/tr><\/thead><tbody><tr><td>SMTP credentials<\/td><td>Moves \u2014 four variables<\/td><td>Minutes<\/td><\/tr><tr><td>Suppression lists, per domain<\/td><td>Export and import<\/td><td>Before first send<\/td><\/tr><tr><td>Stored templates<\/td><td>Move into your repository<\/td><td>Hours to days<\/td><\/tr><tr><td>Webhook verification<\/td><td><strong>Rebuild<\/strong> \u2014 different algorithm<\/td><td>Hours<\/td><\/tr><tr><td><strong>Routes<\/strong><\/td><td><strong>Reimplement as application code<\/strong><\/td><td>The real work<\/td><\/tr><tr><td>Tags and analytics continuity<\/td><td>Map to an equivalent<\/td><td>Low<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Routes are the row that decides your timeline. Nothing else on the market replicates them, so they do not migrate \u2014 they get rewritten as code you own.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The provider-agnostic sequencing \u2014 DNS TTL, SPF lookup limits, the ramp, the rollback plan \u2014 is in our guide to <a href=\"https:\/\/photonconsole.com\/blog\/migrate-email-provider-without-downtime\/\">migrating email providers without downtime<\/a>. Follow that ordering alongside this.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Half Everyone Forgets<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img fetchpriority=\"high\" decoding=\"async\" width=\"1024\" height=\"576\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/Mailgun-Outbound-and-Inbound-Flow-1024x576.png\" alt=\"A PhotonConsole Mailgun diagram showing two email flow paths: outbound email moves right through credential authentication, DNS configuration, and sending, while inbound email moves left through an envelope, filtering rules, and code-based processing. The outbound path is a solid orange arrow, while the inbound path is dashed orange.\" class=\"wp-image-557\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/Mailgun-Outbound-and-Inbound-Flow-1024x576.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/Mailgun-Outbound-and-Inbound-Flow-300x169.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/Mailgun-Outbound-and-Inbound-Flow-768x432.png 768w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/Mailgun-Outbound-and-Inbound-Flow-1536x864.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/Mailgun-Outbound-and-Inbound-Flow.png 1672w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">Outbound is a credential change. Inbound is a rules engine you have to replace.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Before you plan anything, answer one question: <strong>does Mailgun receive mail for you?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Check for an MX record pointing at Mailgun on your sending domain or subdomain, then list your routes. If either turns up something, you have two migrations rather than one, and they have different shapes:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Outbound<\/strong> is a credential change plus the standard DNS and ramp sequence. Genuinely an afternoon.<\/li>\n\n\n\n<li><strong>Inbound<\/strong> is a rules engine with regex matching, actions, priority ordering and a storage window. No destination provider has an equivalent, so it becomes application code.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The reason this gets missed is that inbound routing, once configured, never needs attention. Someone set up <code>support@<\/code> forwarding to the helpdesk in 2024, it worked, and nobody has opened that screen since. It is not in your codebase, not in your deployment pipeline, and not in anyone&#8217;s head.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Common Mistake<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cancelling the Mailgun account once outbound traffic is fully migrated. Outbound and inbound are independent, and the delivery metrics you are watching say nothing about whether mail is still arriving. Worse, inbound failure is silent in both directions \u2014 senders get a bounce you never see, and your application simply stops receiving the POSTs it used to. Keep the account open and the MX record pointing at Mailgun until inbound has been reimplemented, tested and cut over separately. The account costs one more billing cycle; a fortnight of lost customer replies costs considerably more.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Routes Are a Rules Engine, Not a Setting<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">It is worth seeing exactly what you are replacing, because &#8220;forwarding rules&#8221; undersells it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Mailgun&#8217;s <a href=\"https:\/\/documentation.mailgun.com\/docs\/mailgun\/user-manual\/receive-forward-store\/route-filters\" target=\"_blank\" rel=\"noopener\">route filters<\/a> offer three matching expressions, and they take Python-style regular expressions:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Filter<\/th><th>Matches on<\/th><\/tr><\/thead><tbody><tr><td><code>match_recipient(pattern)<\/code><\/td><td>The recipient address, by regex<\/td><\/tr><tr><td><code>match_header(header, pattern)<\/code><\/td><td>Any message header, by regex<\/td><\/tr><tr><td><code>catch_all()<\/code><\/td><td>Anything no earlier route matched<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Filters combine with boolean logic, so a single route can be as specific as this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># A real Mailgun route expression\nmatch_recipient('^(.*)@example.com$')\n  and match_header(\"Content-Language\", \"^(.*)en-US(.*)$\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And the <a href=\"https:\/\/documentation.mailgun.com\/docs\/mailgun\/user-manual\/receive-forward-store\/route-actions\" target=\"_blank\" rel=\"noopener\">route actions<\/a> are three:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>forward()<\/code><\/strong> \u2014 sends the message on to an email address or a URL, and accepts several destinations separated by commas.<\/li>\n\n\n\n<li><strong><code>store()<\/code><\/strong> \u2014 keeps the message on Mailgun&#8217;s servers for retrieval, and can notify a URL with a retrieval link. Mailgun documents this storage as lasting <strong>up to 3 days<\/strong>.<\/li>\n\n\n\n<li><strong><code>stop()<\/code><\/strong> \u2014 ends priority evaluation so lower-priority routes are not checked.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">So a complete inbound setup is an ordered list of regex-matched rules with fall-through behaviour, side effects, and a three-day retention buffer. That is a program. It happens to live in a web form.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The three-day storage window deserves its own thought if you use <code>store()<\/code>. Any message currently sitting in Mailgun&#8217;s storage, waiting for your application to retrieve it, disappears three days after arrival \u2014 and cancelling the account removes access immediately rather than at the end of that window. Before you close anything, confirm your retrieval process has drained the queue, and check whether any retrieval failures from the preceding days left messages your application never collected. This is the one piece of inbound data that genuinely expires.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Reimplementing Routes as Code<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img decoding=\"async\" width=\"1024\" height=\"576\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/From-Route-Box-to-Code-Rules-1024x576.png\" alt=\"A PhotonConsole technical diagram comparing email routing through a Route interface with code-based rules. The left panel shows three routing fields: match_recipient(...), forward(...), and priority: 0. An orange arrow points to a code editor on the right containing an array of rules. The diagram highlights the benefits of code-based routing: \u201cReviewable. Testable. Version controlled.\u201d\" class=\"wp-image-558\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/From-Route-Box-to-Code-Rules-1024x576.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/From-Route-Box-to-Code-Rules-300x169.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/From-Route-Box-to-Code-Rules-768x432.png 768w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/From-Route-Box-to-Code-Rules-1536x864.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/11\/From-Route-Box-to-Code-Rules.png 1672w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">The same logic, moved from a web form into something your test suite can see.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The translation is mechanical once you have the routes written down. Export them, order them by priority, and express the same logic where you can review and test it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Mailgun routes, as code you own\nconst ROUTES = &#91;\n  { match: \/^support@\/i,        handler: createTicket,      stop: true },\n  { match: \/^invoices?@\/i,      handler: fileToAccounting,  stop: true },\n  { match: \/^bounce\\+(.+)@\/i,   handler: processBounceTag,  stop: true },\n  { match: \/.*\/,                handler: deadLetter,        stop: true }\n];\n\nfunction routeInbound(message) {\n  for (const route of ROUTES) {\n    if (route.match.test(message.recipient)) {\n      route.handler(message);\n      if (route.stop) return;\n    }\n  }\n}\n\n# Two things to carry over deliberately:\n#  - Mailgun evaluates by PRIORITY, so array order matters here\n#  - catch_all() becomes the final \/.*\/ entry, not an implicit default<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two translation details cause most of the bugs. Mailgun evaluates routes by priority with <code>stop()<\/code> controlling fall-through, so your array order has to reproduce the priority order rather than the order the routes happen to appear in the UI. And <code>catch_all()<\/code> must become an explicit final rule \u2014 leaving it implicit means unmatched mail vanishes rather than reaching a dead-letter handler.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Where the mail arrives from now depends on your new setup. Most teams point the MX record at whatever inbound service they choose and POST to the same endpoint, which means the routing logic above is the only part that is genuinely new.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Webhook Signature Changes Completely<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If anything verifies Mailgun webhooks \u2014 and it should \u2014 that code is Mailgun-specific and gets replaced.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Per Mailgun&#8217;s <a href=\"https:\/\/documentation.mailgun.com\/docs\/mailgun\/user-manual\/webhooks\/securing-webhooks\" target=\"_blank\" rel=\"noopener\">webhook security documentation<\/a>, the scheme is a hex-encoded HMAC-SHA256 of the <code>timestamp<\/code> and <code>token<\/code> values concatenated with no separator, keyed with your Webhook Signing Key. The documentation is explicit that this key is &#8220;not your API key or your domain sending key&#8221; \u2014 it is a third credential, found under Settings, and it is the one people forget exists.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># What you are replacing\n#   signature = HMAC-SHA256(timestamp + token), hex, keyed with\n#               the account Webhook Signing Key\n#\n# Verification steps Mailgun documents:\n#   1. Concatenate timestamp and token, no separator\n#   2. HMAC-SHA256 with the Webhook Signing Key\n#   3. Compare hexdigest to the signature value\n#   4. Reject on mismatch\n#   5. Optionally cache the token, reject replays\n#   6. Optionally bound the timestamp \u2014 but not too tightly,\n#      because Mailgun notes processing delays occur<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">There is a structural trap here worth knowing before you go looking for your verification code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mailgun sends two different payload shapes.<\/strong> Event webhooks nest <code>timestamp<\/code>, <code>token<\/code> and <code>signature<\/code> inside a JSON object named <code>signature<\/code>, alongside <code>event-data<\/code>. But <code>forward()<\/code> and <code>store()<\/code> route posts send those same fields as <strong>top-level form fields<\/strong>.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Source<\/th><th>Where the signature fields sit<\/th><th>Content type<\/th><\/tr><\/thead><tbody><tr><td>Event webhooks<\/td><td>Nested in a JSON <code>signature<\/code> object, beside <code>event-data<\/code><\/td><td>JSON body<\/td><\/tr><tr><td>Route <code>forward()<\/code> and <code>store()<\/code><\/td><td>Top-level fields<\/td><td>Form fields<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">So you may well have two verification code paths, written months apart, possibly by different people \u2014 and if you only find one of them during the migration, the other endpoint either breaks or, worse, quietly stops verifying. Search for the signing key constant rather than for a function name.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finding Every Mailgun Integration Point<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Grep for the Webhook Signing Key variable name, not for a function \u2014 it reveals every verification path<\/li>\n\n\n\n<li>Check for both payload shapes: a nested <code>signature<\/code> object and top-level form fields<\/li>\n\n\n\n<li>Look up your MX records; an MX pointing at Mailgun means inbound is live whether you remember it or not<\/li>\n\n\n\n<li>List every route and record its priority order before changing anything<\/li>\n\n\n\n<li>Grep for <code>api.mailgun.net<\/code> and <code>api.eu.mailgun.net<\/code> \u2014 the EU base URL is easy to miss<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Your new provider will sign differently \u2014 a different algorithm, a different field layout, or both. This is the moment to put verification behind one function and one mapping, so a future change is a single edit. Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/transactional-email-webhooks-explained-the-complete-engineering-guide-for-reliable-email-event-processing\/\">transactional email webhooks<\/a> covers verification and idempotency for the rebuilt consumer.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Suppression Data Is Scoped Per Domain<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Lighter than SendGrid&#8217;s six separate lists, but with one catch that costs people.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Mailgun&#8217;s suppression endpoints are addressed per sending domain \u2014 the bounces endpoint, for instance, is scoped as <code>\/v3\/{domain}\/bounces<\/code>. If you send from more than one domain or subdomain, each has its own suppression data, and exporting only your primary domain leaves the rest behind.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Exporting Suppressions Completely<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>List every sending domain and subdomain on the account first \u2014 not just the one you think of as yours<\/li>\n\n\n\n<li>Export every suppression list for each domain separately via the API<\/li>\n\n\n\n<li>Store the exports in your own system, not only in the new provider; this is your data<\/li>\n\n\n\n<li>Import before the first production send, never after the migration<\/li>\n\n\n\n<li>Re-export on your final day \u2014 addresses that bounced during the ramp exist only in Mailgun<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Import before the first send rather than after. A new sending path has no bounce history, so mailing previously-bounced addresses from it teaches every major receiver that this identity mails addresses that do not exist. Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/how-to-reduce-email-bounce-rate-for-saas-applications-a-production-infrastructure-guide\/\">reducing bounce rate<\/a> covers why those first signals carry disproportionate weight.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Templates, and the Subdomain Question<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two smaller items that still need deciding.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Stored templates.<\/strong> If your content lives in Mailgun&#8217;s template store with versioning, retrieve it and put it in your repository rather than in another provider&#8217;s template store. Templates under version control are reviewable, diffable and testable in CI, and they make the next migration a configuration change instead of a project. Test every template against payloads with missing and null fields before sending a single one to a real recipient \u2014 a renderer that silently outputs nothing for an undefined variable behaves differently from one that outputs the literal placeholder.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The sending subdomain.<\/strong> Mailgun has long encouraged sending from a subdomain such as <code>mg.yourdomain.com<\/code>, so many accounts are set up that way. If yours is, decide deliberately whether to keep that subdomain on the new provider or move to a different one. Keeping it preserves whatever reputation the subdomain has built. Changing it means starting that reputation again, which is a real cost and an avoidable one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Whichever you choose, the destination needs to support it. PhotonConsole authenticates a subdomain the same way it authenticates a root domain, so keeping <code>mg.yourdomain.com<\/code> is a DNS exercise rather than a reason to change your From address.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Either way, publish an A or MX record for any subdomain you send from. A sending subdomain with no A or MX record in public DNS is treated with more suspicion by some receivers, and it is one record to add.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Check Which Region You Are In<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Mailgun runs separate EU infrastructure with its own API base URL, and the two are not interchangeable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If your integration talks to <code>api.eu.mailgun.net<\/code> rather than <code>api.mailgun.net<\/code>, your account, domains and data live in the EU region. That matters for two reasons during a migration: your exports must come from the right region, and if data residency was the reason you chose the EU region in the first place, you need to confirm your destination offers an equivalent before committing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Grep for both base URLs. A codebase that has grown over a few years sometimes contains one of each.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Authentication and Cutover<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Standard sequence, with both providers authorised during the parallel run.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># During migration \u2014 one SPF record, both senders\nv=spf1 include:mailgun.org include:relay.photonconsole.com ~all\n\n# New DKIM on its own selector, alongside Mailgun's\nphoton._domainkey  CNAME  dkim.photonconsole.com<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Take Mailgun&#8217;s exact include from your own account rather than from this article, since it differs by region and setup. Count your SPF lookups before adding the new include \u2014 exceeding the limit in <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc7208.html\" target=\"_blank\" rel=\"noopener\">RFC 7208<\/a> returns <code>permerror<\/code> and fails authentication for both providers simultaneously. Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC<\/a> covers alignment, and the free <a href=\"https:\/\/www.photonconsole.com\/email-deliverability-checker.php\">email deliverability checker<\/a> shows what your domain publishes right now.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then validate three things with real sends before moving production traffic:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Authentication aligns<\/strong> \u2014 <a href=\"https:\/\/mxtoolbox.com\/\" target=\"_blank\" rel=\"noopener\">MXToolbox<\/a> confirms the combined record resolves, and <a href=\"https:\/\/www.mail-tester.com\/\" target=\"_blank\" rel=\"noopener\">Mail Tester<\/a> scores an end-to-end send. Receivers including <a href=\"https:\/\/support.google.com\/mail\/answer\/81126\" target=\"_blank\" rel=\"noopener\">Gmail<\/a> publish sender expectations worth reading before rather than after.<\/li>\n\n\n\n<li><strong>The new webhook verifies and parses correctly<\/strong>, tested by triggering a real bounce to a known-invalid address.<\/li>\n\n\n\n<li><strong>Your reimplemented routes handle real inbound mail<\/strong>, including the catch-all case.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Ramp outbound by percentage with Mailgun still live, per the sequence in <a href=\"https:\/\/photonconsole.com\/blog\/migrate-email-provider-without-downtime\/\">the migration guide<\/a>. Cut inbound over separately and after outbound is settled \u2014 one MX change at a time, never both directions in the same window.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.photonconsole.com\/\">PhotonConsole<\/a> issues SMTP credentials on signup, so the outbound path can be live and testable the same day while you work on the routing rewrite. <a href=\"https:\/\/www.photonconsole.com\/pricing.php\">Pricing<\/a> includes 5,000 free emails a month, which covers the whole parallel-running phase without a second bill.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pro Tips<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Check your MX records first.<\/strong> It is the fastest way to find out whether you have one migration or two.<\/li>\n\n\n\n<li><strong>Export routes with their priority order.<\/strong> The order is the logic, and the UI does not always display it as evaluated.<\/li>\n\n\n\n<li><strong>Make <code>catch_all()<\/code> an explicit final rule.<\/strong> Implicit defaults lose mail silently.<\/li>\n\n\n\n<li><strong>Grep for the signing key, not the function.<\/strong> It finds both webhook code paths.<\/li>\n\n\n\n<li><strong>Export suppressions per domain.<\/strong> They are scoped per sending domain, not per account.<\/li>\n\n\n\n<li><strong>Drain <code>store()<\/code> before cancelling.<\/strong> Three days of retention ends the moment the account closes.<\/li>\n\n\n\n<li><strong>Move inbound after outbound, never together.<\/strong> Two MX-level changes in one window makes diagnosis impossible.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Related Reading<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/photonconsole.com\/blog\/migrate-email-provider-without-downtime\/\">Migrating email providers without downtime<\/a> \u2014 the sequence and timing to follow alongside this<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/mailgun-pricing-explained\/\">Mailgun pricing explained<\/a> \u2014 if overage charges are what started this<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/best-mailgun-alternatives\/\">Best Mailgun alternatives<\/a> \u2014 if you have not chosen a destination yet<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/migrate-from-sendgrid\/\">Migrating away from SendGrid<\/a> \u2014 where content, not routing, is the lock-in<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/migrate-from-amazon-ses\/\">Migrating from Amazon SES<\/a> \u2014 where the lock-in is AWS plumbing<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/transactional-email-webhooks-explained-the-complete-engineering-guide-for-reliable-email-event-processing\/\">Transactional email webhooks explained<\/a> \u2014 rebuilding the event consumer<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently Asked Questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">What is the hardest part of leaving Mailgun?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Routes. Mailgun&#8217;s inbound routing is a regex-based rules engine with priority ordering, and no destination provider replicates it, so it has to be reimplemented as application code.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does Mailgun receive email as well as send it?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, if you have routes configured and an MX record pointing at Mailgun. This is the half of the migration teams most often forget, and inbound failure is silent.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What are Mailgun&#8217;s route filters and actions?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Filters are <code>match_recipient()<\/code>, <code>match_header()<\/code> and <code>catch_all()<\/code>, all supporting Python-style regular expressions. Actions are <code>forward()<\/code>, <code>store()<\/code> and <code>stop()<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How long does Mailgun store inbound messages?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The documentation states <code>store()<\/code> keeps messages for up to 3 days. Drain anything pending before cancelling, because closing the account ends access immediately.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How does Mailgun sign webhooks?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A hex-encoded HMAC-SHA256 of the <code>timestamp<\/code> and <code>token<\/code> concatenated with no separator, keyed with your Webhook Signing Key \u2014 which is separate from your API key and your domain sending key.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why might I have two webhook verification code paths?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Event webhooks nest the signature fields in a JSON <code>signature<\/code> object, while <code>forward()<\/code> and <code>store()<\/code> route posts send them as top-level form fields. Many teams wrote a handler for each.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need to export suppressions for every domain?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. Mailgun&#8217;s suppression endpoints are scoped per sending domain, so exporting only your primary domain leaves the others behind.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I keep my Mailgun sending subdomain?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Keeping it preserves the reputation that subdomain has built. Changing it starts that reputation over, which is a real and usually avoidable cost.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Mailgun migrations fail in a specific and avoidable way. The outbound work is genuinely easy \u2014 a credential change and a ramp \u2014 which creates false confidence, and then the account gets cancelled while mail is still arriving through an MX record nobody checked.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the first action is not a credential swap. It is looking up your MX records and listing your routes, because that tells you whether this is one project or two. If routes exist, they are a regex rules engine with priority ordering and a three-day storage buffer, and replacing them is the work that sets your timeline.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Beyond that, three things need attention. Export suppressions for every sending domain, not just the obvious one. Find both webhook verification paths by grepping for the signing key rather than a function name. And move outbound first, inbound second, never in the same window.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The upside of doing it properly is that the routing logic ends up somewhere you can test it. A <a href=\"https:\/\/www.photonconsole.com\/relay.php\">reliable SMTP provider<\/a> handles the sending side with standard SMTP and a flat per-thousand rate, and your inbound rules end up in version control where a new engineer can read them \u2014 which is a better place for them than a web form nobody has opened in two years.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Read More<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/www.photonconsole.com\/email-deliverability-checker.php\">Free Email Deliverability Checker<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/mailgun-alternative.php\">PhotonConsole as a Mailgun Alternative<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/migrate-email-provider-without-downtime\/\">Migrating Email Providers Without Downtime<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC Explained Simply<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-configuration\/\">SMTP Configuration: Complete Setup Reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/relay.php\">PhotonRelay: SMTP Relay Service<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/pricing.php\">PhotonConsole Pricing<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>The sending migration goes well. Four environment variables, a ramp over a few days, delivery metrics clean. You decommission the Mailgun account on a Friday. On Monday someone asks why nobody has replied to any support ticket since Thursday. Mailgun is the one major provider that is genuinely bidirectional. It sends your mail and it [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":556,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[31],"tags":[591,592],"class_list":["post-555","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-smpt-relay-service","tag-sending-migrates-in-an-afternoon-routes","tag-webhook-signatures-and-per-domain-suppression-data-are-the-real-work-here-is-the-order-that-avoids-lost-mail"],"_links":{"self":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/555","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/comments?post=555"}],"version-history":[{"count":1,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/555\/revisions"}],"predecessor-version":[{"id":559,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/555\/revisions\/559"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media\/556"}],"wp:attachment":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media?parent=555"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/categories?post=555"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/tags?post=555"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}