Mailgun webhook signature verification failing? It doesn't sign the body

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.

What Mailgun actually signs

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)

Cause 1: verifying the body like it's GitHub

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.)

Cause 2: the wrong key

Cause 3: concatenation order and types

Cause 4: legacy webhooks and inbound routes are form-encoded

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.

Cause 5: subaccount events (parent-signature)

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.

Delivery facts worth knowing

Isolate it in one minute with a capture URL

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):

  1. Create a bin and set it as a webhook URL in Mailgun (Sending → Webhooks), or as a route destination for inbound mail.
  2. In the bin's settings, choose Mailgun as the signature scheme and paste your Webhook Signing Key.
  3. Trigger a test delivery. Every capture gets a ✓/✗ badge: ✓ means the key is right and the failure is in your verifier's inputs (Causes 1, 3, 4); ✗ means the key itself is wrong (Cause 2).
  4. The capture shows the exact payload — check whether your traffic is the JSON shape or the form-encoded shape before writing the parser.

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