Adding a webhook
Create a URL above, then open Mailgun → Send → Sending → Webhooks for your domain and add a webhook for the event type you want. Paste the URL and press Test webhook — Mailgun immediately POSTs a synthetic event of that type, with a genuine signature.
The signing key is somewhere else: Settings → Webhooks, as the HTTP webhook signing key. One key covers all of your domains and all event types. It is not your API key, and this is worth double-checking before you debug anything else, because the failure looks the same either way.
The signature lives in the body
Every other provider on this site puts its signature in a header and signs the raw body. Mailgun inverts both halves:
{
"signature": {
"timestamp": "1750000000",
"token": "00000000000000000000000000000000000000000000000000",
"signature": "0000000000000000000000000000000000000000000000000000000000000000"
},
"event-data": { "event": "delivered", "recipient": "buyer@example.com", ... }
}
Two consequences follow, and they are not cosmetic.
You must parse before you verify. The usual advice — capture the raw bytes, verify, only then parse — cannot apply, because the signature you are verifying against is inside the JSON. Parse first, verify from the parsed values, and be aware that a malformed body reaches your JSON parser before any authentication happens.
The event data is not signed. The HMAC covers only timestamp + token:
const signed = timestamp + token; // no separator
const digest = hmacSha256Hex(signingKey, signed);
const ok = timingSafeEqual(digest, signature);
So a valid signature says Mailgun issued this token at this time. It says nothing at all about the event-data block sitting next to it. Anyone who has seen one delivery can attach that same signature object to any payload they like.
Which is why the token check is not optional
Mailgun’s design assumes you will do the other half of the work:
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 60 * 5) return reject();
if (await seenTokens.has(token)) return reject();
await seenTokens.add(token, { ttl: 60 * 10 });
The token is 50 characters and intended for exactly one delivery. With the timestamp window and the replay cache in place the scheme is sound; with only the HMAC it is close to decorative. This is the rare case where skipping a check that “already passes” leaves a real hole.
Reading event-data
"event-data": {
"event": "delivered",
"log-level": "info",
"recipient": "buyer@example.com",
"message": { "headers": { "message-id": "...", "subject": "..." } },
"delivery-status": { "code": 250, "message": "OK" },
"user-variables": { "user_id": "1234" }
}
The hyphens are the practical annoyance. payload['event-data']['delivery-status'] is the only way to reach that value in JavaScript, Python, or Ruby, and an autocompleted event_data returns undefined without complaint.
user-variables is where your own identifiers come back — set them with v:user_id when sending, and they appear on every event for that message. message.headers['message-id'] is the join key back to the send.
On a failed event, read severity before doing anything destructive. permanent means the address is bad and should be suppressed. temporary means the receiving server was busy or throttling, and Mailgun will try again on its own — suppressing on a temporary failure quietly deletes deliverable recipients.