Mailgun webhook guide: events, Routes, and signature verification
A complete Mailgun webhook guide - event webhooks vs inbound Routes, the payload shape, HMAC signature verification in Node, retries, and testing.
Dvir Atias
Founder
A Mailgun webhook is an HTTP POST that Mailgun sends to a URL you own whenever an email event happens - delivered, opened, clicked, failed, complained, or unsubscribed. Mailgun actually has two separate systems that people constantly conflate: event webhooks, which report what happened to mail you sent, and inbound Routes, which POST email your users send you. This guide covers both, plus how to verify the HMAC signature, how retries work, and how to test locally.
What is a Mailgun webhook?
An event webhook is a per-domain subscription. You give Mailgun an HTTPS endpoint for a specific event type, and every time that event fires for that sending domain, Mailgun POSTs a JSON payload to your endpoint. This is how you keep your own database in sync with reality: instead of polling the Mailgun API to ask "did that message land," you let Mailgun push the answer to you the moment it knows.
You configure event webhooks two ways: in the dashboard under Sending, then Webhooks, or programmatically through the Webhooks API. Each event type gets its own URL, so you can route bounces to one handler and clicks to another if you want.
What is the difference between Mailgun webhooks and Routes?
This is the single most confusing thing about Mailgun, so it is worth stating plainly. There are two different features and both end up POSTing to a URL:
- Event webhooks track outbound mail. You sent an email through Mailgun, and the webhook tells you what happened to it - it was delivered, the recipient opened it, a link was clicked, it bounced permanently, or the person complained.
- Inbound Routes handle incoming mail. Someone sends an email to an address on your Mailgun domain. A Route is a rule that matches the recipient, then runs an action: forward it, store it, or POST the parsed message to your app.
So "does Mailgun receive email" and "does Mailgun tell me about email I sent" are answered by two different subsystems. If you want an agent or app to read inbound mail, you want Routes, not event webhooks.
Mailgun event types
Event webhooks cover the full lifecycle of a message. These are the ones you will actually wire up:
| Event | Fires when | Common use |
|---|---|---|
delivered | The receiving server accepted the message | Mark as sent in your database |
opened | The recipient opened the email | Engagement tracking |
clicked | The recipient clicked a tracked link | Engagement tracking |
permanent_fail | A hard bounce - the address is dead | Suppress the address |
temporary_fail | A soft bounce - retrying | Monitor deliverability |
complained | The recipient hit "spam" | Suppress and investigate |
unsubscribed | The recipient used the unsubscribe link | Update consent state |
Note that delivered is excluded from Mailgun's retry logic, so if your endpoint is down when a delivery event fires, you will not get a second chance at it. Design your handler to reconcile from the API for anything that must be exactly correct.
How do you verify a Mailgun webhook signature?
Every webhook POST includes a signature object with three fields: timestamp, token, and signature. You verify authenticity by recomputing the signature yourself and comparing:
- Concatenate the
timestampand thetokenwith no separator between them. - Compute an HMAC of that string using SHA-256 and your HTTP webhook signing key as the secret.
- Compare your hex digest to the
signaturefield using a constant-time comparison.
The signing key is not your API key. It lives separately in the dashboard under Sending, then API keys, labeled "HTTP webhook signing key." Here is a working Node verification:
import crypto from "node:crypto";
function verifyMailgun({ timestamp, token, signature }, signingKey) {
const digest = crypto
.createHmac("sha256", signingKey)
.update(timestamp + token)
.digest("hex");
// Constant-time compare - never use === on secrets
const a = Buffer.from(digest);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express handler
app.post("/hooks/mailgun", (req, res) => {
const ok = verifyMailgun(req.body.signature, process.env.MAILGUN_SIGNING_KEY);
if (!ok) return res.status(403).send("bad signature");
const event = req.body["event-data"];
console.log(event.event, event.recipient);
res.status(200).send("ok");
});Two hardening steps worth adding: cache the token and reject any repeat, which blocks replay attacks, and optionally reject timestamps that are wildly far from now. Keep the timestamp window generous, because webhook processing can be delayed for reasons outside your control.
How does Mailgun retry failed webhooks?
If your endpoint returns a 5xx status or times out, Mailgun treats the delivery as failed and retries with backoff over a period of several hours, as of July 2026. To tell Mailgun to stop retrying a specific request - for example when you have already recorded the event - return HTTP 406. A 200 acknowledges success. Because retries mean the same event can arrive more than once, your handler must be idempotent: key off the message id and event so a duplicate POST is a no-op.
How do you receive inbound email with Mailgun Routes?
A Route has a filter expression and one or more actions. A typical rule reads "match the recipient support@yourdomain.com, then store() the message and forward() a parsed copy to https://your-app.com/inbound." Mailgun POSTs the incoming message to that URL with the sender, recipient, subject, body-plain, stripped-text, and any attachments as multipart form fields. Inbound POSTs are signed with the same timestamp-plus-token scheme, so the same verification code applies.
An agent-native alternative
Mailgun was built for marketing and transactional sending, with receiving bolted on through Routes. If you are building AI agents that need their own inboxes, that split - one system for sent events, another for inbound, a per-domain dashboard for configuration - becomes friction fast. AgenticEmail treats inboxes as first-class objects instead.
Create an inbox with one call and it can receive instantly on the shared domain:
curl -X POST https://api.agenticemail.dev/v1/inboxes \
-H "Authorization: Bearer $AGENTICEMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "username": "support" }'Register one webhook that covers both directions - inbound mail and delivery status - optionally scoped to specific inboxes:
curl -X POST https://api.agenticemail.dev/v1/webhooks \
-H "Authorization: Bearer $AGENTICEMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/hooks/email",
"event_types": ["message.received", "message.delivered", "message.bounced"]
}'Payloads are signed per the Standard Webhooks spec - webhook-id, webhook-timestamp, and webhook-signature headers with a whsec_ secret - so verification uses an off-the-shelf library instead of hand-rolled HMAC. A delivery log with redelivery is built in, and if you would rather stream events, connect a WebSocket at wss://api.agenticemail.dev/v1/events?token=am_... and skip endpoints entirely.
Frequently asked questions
Can Mailgun receive email?
Yes, through inbound Routes, not event webhooks. A Route matches the recipient address on your domain and can store the message, forward it, or POST the parsed email to your app. Event webhooks only report on mail you send.
How do I test Mailgun webhooks locally?
Expose your local server with a tunnel such as ngrok or Cloudflare Tunnel, register the public URL as your webhook target, then send a test through Mailgun or use the "Send test webhook" button in the dashboard. Log the raw body so you can inspect the exact payload shape before writing your handler.
What is the Mailgun webhook signing key?
It is a dedicated secret, separate from your API key, used only to sign webhook payloads. Find it in the dashboard under Sending, then API keys, as the "HTTP webhook signing key." Rotate it there if it leaks, and update your environment variable to match.
Why am I getting duplicate Mailgun webhooks?
Retries. When your endpoint returns a 5xx or times out, Mailgun re-sends the same event, so a slow or briefly failing handler can produce duplicates. Make your handler idempotent by de-duplicating on the message id, and return 200 promptly once you have persisted the event.
Should I use a webhook or the events API?
Use webhooks for real-time reactions and to keep your database current, and fall back to the events API or your own reconciliation job for anything that must be exactly correct, since the delivered event is not retried. Comparing providers first? See our best email API rundown.