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.
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:
Two things bite here in practice:
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 Routes | MailKite inbound | |
|---|---|---|
| Routing model | Filter-expression rules you author + order | Point an address/catch-all at a webhook |
| Payload format | Form-encoded (multipart/form-data) | Single JSON webhook |
| Body | body-plain / body-html (+ lossy stripped-text) | text / html, pre-decoded |
| Attachments | Multipart file parts, inline | Metadata + short-lived signed url |
| Sender auth | Dig SPF/DKIM out of headers | auth{spf,dkim,dmarc,spam} in payload |
| Signature check | timestamp+token+signature HMAC you assemble | MailKite.verifyWebhook(sig, rawBody, secret) |
| Free tier | Inbound gated to paid plans | 3,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.
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.