All posts
The Mailgun Routes alternative for developers
Gabe 14 min read

The Mailgun Routes alternative for developers

Mailgun Routes is a filter-expression engine for inbound mail: match_recipient rules fire forward() and store() actions, and the parsed message arrives as form-encoded fields you decode and verify. MailKite (which we build) drops the rule DSL: point an address or catch-all at a webhook and the message arrives as one JSON payload with decoded text/html, SPF/DKIM/DMARC results, and signed attachment URLs. A fair comparison with working code for both sides.

Here is that whole difference in one picture: the same inbound email, and everything you author and operate to receive it on each side. The rest of this post is the honest version of the diagram: where Mailgun genuinely wins, where the rule engine and the form payload bite as they scale, and the 20 lines of working code that are the entire MailKite side.

Mailgun Routes sender MailgunMX + parse routespriority rules POSTform-enc your handlerHMAC + form decode your app …plus the rule expressions you author, prioritize, keep in sync with your app, and re-debug when a message doesn't match ← form decode, timestamp+token HMAC, attachment parts: yours MailKite sender MX edgeparse + auth JSON webhooksigned, retried, replayable your app the 20 lines below are the whole "your app" integration
Receiving one email: what you author and operate with Mailgun Routes vs MailKite. Same input, one rule engine and one form decoder apart.

Here is the entire MailKite side. Not a fragment: this runs as pasted on Node 18+, one dependency (npm install mailkite).

// The whole MailKite integration: verify, parse, act.
import { createServer } from "node:http";
import { MailKite } from "mailkite";

const SECRET = process.env.MAILKITE_WEBHOOK_SECRET ?? "whsec_demo_secret";

createServer(async (req, res) => {
  let raw = "";
  for await (const chunk of req) raw += chunk;

  // signature check, replay window, constant-time compare: one call
  if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], raw, SECRET)) {
    res.writeHead(401).end();
    return;
  }

  const event = JSON.parse(raw); // one JSON object: no form decoding, no multipart parts
  if (event.type === "email.received") {
    console.log(event.from.address, "·", event.subject, "·", event.text);
  }
  res.writeHead(200).end("ok");
}).listen(3000);

(Can’t take a dependency? The header is x-mailkite-signature: t=<ms>,v1=<hex>, where v1 is an HMAC-SHA256 over "<t>.<rawBody>" with your webhook secret and t is in milliseconds. You can hand-roll that with node:crypto, but the SDK call also handles the replay window and the constant-time compare, which is where hand-rolled versions usually go wrong. Details in webhook security.)

Where Mailgun wins, honestly

Routes is a capable feature and this is a comparison, not a hit piece. Mailgun has moved inbound mail for well over a decade, Routes genuinely parses MIME for you (you get body-plain and body-html, not raw multipart), and the expression engine is genuinely expressive: you can match on recipient, headers, or a catch-all and chain actions. If you already live in Mailgun for outbound and your routing is a couple of stable rules, it works and you don’t need us. This post is for when the rules multiply, the payload fights you, or the account underneath you keeps changing hands.

What Routes actually gives you

You write rules in Mailgun’s filter syntax. A typical one:

Priority: 1
Expression:  match_recipient(".*@inbound.myapp.ai")
Actions:     forward("https://myapp.ai/hooks/mailgun"), stop()

Every inbound message is tested against every route in priority order; matching routes fire their actions, and stop() halts evaluation. It’s expressive, and it’s also a small rule language you now own: authored, ordered, kept in sync with your app, and debugged when a message doesn’t match what you expected.

When a route forwards to your URL, Mailgun POSTs form-encoded data (multipart/form-data when there are attachments). Your handler decodes the form, verifies an HMAC over timestamp + token, and pulls attachments out as file parts. This is Mailgun’s documented idiom:

// The Mailgun side: form decode + timestamp/token HMAC + attachment parts.
import express from "express";
import multer from "multer";
import crypto from "node:crypto";

const app = express();
const upload = multer(); // routes POST multipart/form-data when attachments ride along

app.post("/hooks/mailgun", upload.any(), (req, res) => {
  const { timestamp, token, signature } = req.body;
  const expected = crypto
    .createHmac("sha256", process.env.MAILGUN_SIGNING_KEY)
    .update(timestamp + token)
    .digest("hex"); // Mailgun's documented scheme; use a timing-safe compare in production
  if (expected !== signature) return res.sendStatus(401);

  console.log(req.body.from, "·", req.body.subject, "·", req.body["body-plain"]);
  // attachments: req.files, multipart parts you extract and store yourself
  res.sendStatus(200);
});

app.listen(3000);

Stack up everything that lives between Mailgun’s MX and your business logic and Routes is a column you own end to end:

email arrivesto an inbound address you own Mailgun MX + parseMIME decoded into fields priority route engineexpressions you author and order form-encoded POSTmultipart when attachments ride your handlerHMAC, form decode, attachment parts your app logicfinally, act on the email Every box above the blue one is yours to author, order, and operate. On MailKite, the parsed JSON webhook is the only box before your app.
The Mailgun Routes receive pipeline, stage by stage. MailKite collapses every gray stage into one signed JSON webhook.

Two things bite here in practice:

  • The convenience fields can eat content. Mailgun offers stripped-text and stripped-signature: the message with quoted replies and signatures removed. Reply/signature stripping is an unsolved problem industry-wide, so stripped-text will sometimes silently drop a line of the actual message along with the quote. Build on it without also keeping body-plain and you lose real content and never see it happen.
  • Attachments are still yours to extract. They arrive as multipart parts you pull out of the request body (not as a URL you fetch on demand), so a large file rides inline in the POST.

There’s also a platform reality worth naming plainly: inbound routing has drifted behind paid plans over the years, and Mailgun has changed owners more than once (Rackspace → Pathwire → Sinch). Neither is a knock on the parsing, but “will my inbound pipeline and its pricing still look the same next year” is a fair question when the product keeps getting passed along. (See Mailgun’s own Routes documentation for the current rule syntax and plan gating.)

The comparison, no adjective inflation

Mailgun RoutesMailKite inbound
Routing modelFilter-expression rules you author + orderPoint an address/catch-all at a webhook
Payload formatForm-encoded (multipart/form-data)Single JSON webhook
Bodybody-plain / body-html (+ lossy stripped-text)text / html, pre-decoded
AttachmentsMultipart file parts, inlineMetadata + short-lived signed url
Sender authDig SPF/DKIM out of headersauth{spf,dkim,dmarc,spam} in payload
Signature checktimestamp+token+signature HMAC you assembleMailKite.verifyWebhook(sig, rawBody, secret)
Free tierInbound gated to paid plans3,000 msgs/mo, 100/day

The through-line: Mailgun parses the body for you (credit where due), but you still own the rule engine, the form decoding, the attachment extraction, and the auth inference. With MailKite that work (and the routing config) is done before the webhook reaches you.

What the JSON looks like

Same inbound email, delivered parsed:

{
  "id": "msg_2Hk9…",
  "type": "email.received",
  "from": { "address": "ada@example.com" },
  "to": [{ "address": "support@myapp.ai" }],
  "subject": "Re: invoice #1042",
  "text": "Looks good — approved!",
  "html": "<p>Looks good — approved!</p>",
  "threadId": "<a1b2c3@mail.example.com>",
  "auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
  "attachments": [
    { "id": "msg_2Hk9…:0", "filename": "po.pdf", "contentType": "application/pdf",
      "size": 18213, "url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…" }
  ]
}

No route to author, no stripped-text to second-guess (you get the full text), and attachments out of the payload as signed URLs. The auth block matters the moment you act on an email: a forged From: is an authorization decision, and having SPF/DKIM/DMARC in the payload means you don’t infer trust from raw headers or trust blindly.

Treat the From header as a claim, not a factIf an inbound email can trigger an action (reset a password, approve an invoice, run an agent), branch on the auth block, never on the display name. A message whose spf, dkim, or dmarc failed should not reach the code path a verified one does. On Mailgun you dig those results out of the raw headers yourself; MailKite hands them to you as fields, so the check is a lookup, not header archaeology.

Per-address routing that would be a match_recipient rule in Mailgun is just a different address (or a catch-all) pointed at your webhook. The same 20-line handler exists for Python, Ruby, Go, PHP, and Java; see the receiving docs and webhook security.

Where I won’t overclaim

Mailgun is a mature sending platform with real deliverability tooling and a decade-plus track record, and unlike some inbound options, Routes genuinely parses MIME rather than dumping raw multipart on you. If you’re already on Mailgun for outbound and your inbound is a couple of stable routes that work, switching purely for inbound may not be worth it. My claim is narrower and specific: for the inbound direction, MailKite replaces the route-expression engine and the form-decoding-plus-attachment work with a single parsed JSON webhook and dashboard routing, without gating inbound behind a plan tier. That’s the friction Routes accumulates as it scales, and it’s the one we built for.

If neither fits, the other managed options are SendGrid’s Inbound Parse (compared here), SES receiving via S3 + Lambda (compared here), and Cloudflare Email Routing as a receive-only primitive (why it can’t reply). The DIY path is a self-hosted Haraka or Postfix box feeding a MIME parser; the inbound pillar sketches what that actually takes to run.

FAQ

What are Mailgun Routes, exactly? Routes are Mailgun’s inbound routing rules: filter expressions like match_recipient(...) and match_header(...) that trigger actions (forward(), store(), stop()) in priority order for each inbound message, with the parsed mail POSTed to your endpoint as form-encoded fields.

Does Mailgun give me the email as JSON? No. It POSTs form-encoded data (multipart/form-data when attachments are present) with fields like body-plain, body-html, and stripped-text. MailKite delivers a single JSON object with text/html decoded, auth{spf,dkim,dmarc,spam}, and attachments as signed URLs.

What’s the catch with stripped-text? It’s the message with quoted replies and signatures removed, produced by a classifier that isn’t perfect, so it can silently drop a line of the real message along with the quote. If you use it, keep body-plain too. MailKite hands you the full text and leaves stripping to you.

Do I have to rewrite my match rules to switch? Usually not one-for-one. A match_recipient rule becomes an address or a catch-all pointed at a webhook in the dashboard; there’s no expression syntax to port or re-order.

Is inbound free? On MailKite, yes within the free tier: 3,000 messages a month, 100 emails/day, metered overage instead of a hard cutoff. Mailgun has moved inbound routing behind paid plans over time.


If your Routes config has grown into a rule engine you maintain, or the platform under it keeps changing hands, there’s a simpler shape. Point a domain at MailKite and your next inbound email arrives as parsed JSON.

Related: Receiving email is the part nobody warns you about makes the full case for inbound, the SendGrid Inbound Parse alternative is this comparison for SendGrid, and Cloudflare Email Routing can’t reply is the same for Cloudflare.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts