MailKite
Get your API key
All guides Inbound email parsing

Mailgun Routes

Inbound parsing turns the email your users send into structured data your app can act on: a reply becomes a support ticket, a forwarded PDF becomes a row in your database, an incoming message becomes a task for an agent. This guide walks through Mailgun's Routes — its inbound routing engine — end to end, then shows the same flow on MailKite.

What you'll need

  • A domain you control, with access to its DNS records.
  • A public HTTPS endpoint to receive the webhook (a tunnel like ngrok works while testing).
  • A Mailgun account, an API key, and the webhook signing key for the first half.

Part 1 — Receive & parse email with Mailgun Routes

1. Point your domain's MX at Mailgun

Add two MX records so mail for the domain is delivered to Mailgun's inbound servers instead of your own. Both records share priority 10:

DNS — yourdomain.com
Host              Type   Priority   Value
yourdomain.com MX 10 mxa.mailgun.org
yourdomain.com MX 10 mxb.mailgun.org

DNS can take a few hours to propagate, so add this early. (Mailgun's EU region uses mxa.eu.mailgun.org / mxb.eu.mailgun.org instead.)

2. Create a Route

A route is a filter expression plus one or more actions. Mailgun evaluates routes in priority order against every incoming message; when an expression matches, its actions run. In the dashboard you'd pick a match type and destination; the same thing over the API is a POST /v3/routes:

create a route
curl -s --user "api:$MAILGUN_API_KEY" \
https://api.mailgun.net/v3/routes \
-F priority=0 \
-F description="inbound to app" \
-F expression='match_recipient(".*@yourdomain.com")' \
-F action='forward("https://yourapp.com/hooks/mailgun")' \
-F action='store()' \
-F action='stop()'

Here match_recipient(".*@yourdomain.com") catches every address on the domain. forward() POSTs the parsed message to your endpoint, store() keeps a copy Mailgun can hand back later, and stop() ends route evaluation so lower-priority routes don't also fire. Use any subset — forward() alone is enough to get webhooks.

3. Parse the payload

When a route forward()s to a URL, Mailgun sends a multipart/form-data POST (it falls back to application/x-www-form-urlencoded when there are no attachments) — not JSON. The message is already parsed into individual form fields:

POST fields — /hooks/mailgun
signature           HMAC of timestamp+token (verify this first)
timestamp seconds since the epoch
token random per-request string
recipient the address the mail was sent to
sender SMTP envelope MAIL FROM
from the From: header
subject the subject line
body-plain full plain-text body
body-html full HTML body
stripped-text body with quoted replies + signature removed
stripped-signature the detected signature block
message-headers all MIME headers, as a JSON string
attachment-count number of attachments
attachment-1..N each file (multipart parts, present when count > 0)

Because your endpoint is public, verify the request before trusting it. Mailgun signs each POST with timestamp, token, and signature — the signature is an HMAC-SHA256 of timestamp + token keyed with your webhook signing key. Recompute it and compare:

handle-inbound.js
import express from "express";
import multer from "multer";
import crypto from "node:crypto";

// Mailgun POSTs multipart/form-data (attachments) or urlencoded (none).
const app = express();
const upload = multer(); // parses both, exposes files on req.files

const SIGNING_KEY = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;

function verify({ timestamp, token, signature }) {
// HMAC-SHA256 of (timestamp + token), keyed with your signing key.
const digest = crypto
.createHmac("sha256", SIGNING_KEY)
.update(timestamp + token)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
}

app.post("/hooks/mailgun", upload.any(), (req, res) => {
const { timestamp, token, signature } = req.body;
if (!verify({ timestamp, token, signature })) {
return res.sendStatus(401); // forged or stale — drop it.
}

const body = req.body["stripped-text"] ?? req.body["body-plain"];
console.log("from", req.body.sender, "·", req.body.subject);
// ...open a ticket, reply, hand to an agent.

// Attachments arrive as multipart file parts (attachment-1 … attachment-N):
for (const file of req.files ?? []) {
// file.originalname, file.mimetype, file.buffer
}

res.sendStatus(200);
});

That's inbound on Mailgun: MX to Mailgun, a route with a match_recipient expression and a forward() action, and a handler that verifies the signature, reads the form fields, and walks the attachment-* parts.

Part 2 — The same flow on MailKite

When we built inbound into MailKite, we wanted three things to be true: one record to receive on your whole domain, a payload you don't have to reshape, and a webhook you can actually trust. Here's the same parse-a-reply flow.

1. Point your domain at MailKite

One MX record makes every address on the domain receivable — no dedicated subdomain, no second record to prioritise:

DNS — yourdomain.com
Host              Type   Priority   Value
yourdomain.com MX 10 mx.mailkite.dev

The domain verifies as soon as that record resolves. See Domains & DNS for the SPF/DKIM/DMARC records that let you send from it too.

2. Register your webhook

Add your endpoint URL in the dashboard or via the API and MailKite POSTs an email.received event the moment mail arrives. Want specific addresses to hit specific endpoints? Add a route — otherwise a catch-all sends the whole domain to one webhook.

3. Parse the payload

The message arrives already decoded — flat from, to, subject, text, and html fields, edge auth verdicts (SPF/DKIM/DMARC/spam), and attachments as signed, time-limited links rather than tokens you have to redeem:

POST /hooks/mailkite
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com", "name": "Ada Lovelace" },
"to": [{ "address": "support@myapp.ai", "name": "Support" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"receivedAt": 1785196800000,
"receivedAtIso": "2026-07-28T00:00:00.000Z",
"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=…"
}
]
}

Because the URL is public, MailKite signs every request. Verify the signature over the raw body first, then handle the event — one message per call, no batch to unwrap:

handle-inbound
import express from "express";
import { MailKite } from "mailkite";

const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
const app = express();
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));

app.post("/hooks/mailkite", (req, res) => {
if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// ...handle event.type === "email.received"
// Confirm receipt: returns the JSON body {"status":"ok"}.
res.type("application/json").send(MailKite.replyOk());
});
Install Docs →

The verifyWebhook helper recomputes the HMAC and rejects forged or stale events — see Verifying signatures for the header format and a no-SDK version. Fields and attachment links are documented in full under Inbound webhooks.

A little about MailKite

MailKite is programmable email for developers and AI agents. Publish one MX record and every address on your domain becomes a signed webhook of clean, decoded JSON — no IMAP, no polling, no MIME wrangling. The same API sends mail, and any inbox can be handed straight to an agent that reads and replies on its own. Inbound and receiving are on the free tier.

Next: Inbound webhooks for routing, retries, and attachments, or the Quickstart to get a verified domain and API key.