Your endpoint receives Mailgun events but your signature check never passes. Mailgun's scheme breaks two assumptions that every GitHub/Stripe-style verifier bakes in: the HMAC does not cover the request body at all, and the signature is not in a header — it travels inside the payload itself. If you copied a raw-body-HMAC verifier from another provider, it can never match, no matter which key you feed it.
Every event webhook POSTs JSON with a signature object alongside the
event-data:
{
"signature": {
"token": "e0b5477167110d68991efc6b9f89f0a11066af27834600e123",
"timestamp": "1770920772",
"signature": "12d99f5a15355c180971bed7494d578b093c958f57766f3fe750761baed12345"
},
"event-data": { … }
}
The signature field is a hex HMAC-SHA256 over
timestamp + token — concatenated in that order, no separator, body
not included. The key is your account's Webhook Signing Key. The
token is a random 50-character string; timestamp is epoch
seconds. Verify a real capture by hand:
printf '%s' '<timestamp><token>' \ | openssl dgst -sha256 -hmac '<webhook-signing-key>' # must equal the "signature" value (hex, lowercase)
The most common failure is structural: computing an HMAC over the raw request body and
comparing it to signature. Mailgun never signs the body. Extract the three
values from the payload and hash timestamp + token — nothing else. (The
flip side: raw-body mangling by your JSON parser is harmless here, which makes this one
of the few schemes where a re-serialized body can't break verification.)
timestamp + token, in that order. Swapping them is a one-character
diff in code and a 100% verification failure.The JSON shape above is the current event-webhook format. Legacy webhooks and
inbound routes (incoming email forwarded to your URL) send the same
three values — timestamp, token, signature — as
form fields (urlencoded or multipart) at the top level of the body, next
to the message fields. Same HMAC, different location. A verifier that only looks for the
JSON signature object returns "missing signature" for every routed
email.
Events for a domain under a subaccount include an extra
parent-signature field, so a single receiving server can verify all
subaccount traffic against the primary account instead of tracking every subaccount's
key. If you operate subaccounts and verification fails only for them, you're likely
checking the wrong one of the two fields with the wrong key.
token values to reject replays, and sanity-check timestamp
against the clock (loosely — their webhook processing can lag).webhooks.mgsend.net) if you want transport-level validation on top.Because the signature is in the body, generic header-HMAC testers can't verify Mailgun at all — there's no header to point them at. A Hookden bin parses the stored payload itself (both the JSON shape and the form-encoded legacy/routes shape):
Developing on localhost? Relay captures to your
machine — and note the pleasant corollary of Mailgun's design: since neither the
body nor the URL is signed, relayed and replayed deliveries keep verifying with no
tricks at all.
Related: Stripe signature verification failed (the opposite scheme — body signed, header-borne), Square and HubSpot (URL-signing schemes), and testing webhook retry behavior.
No signup needed. Or from your terminal: curl https://hookden.pages.dev/new
← All guides · Signature debugger · Docs · Hookden vs webhook.site