Skip to content
TryWebhook
New URL

Email · Free · No signup

Test and debug Mailgun webhooks

Point a Mailgun webhook at a throwaway URL and read the delivery as it arrived — including the timestamp, token, and signature that live inside the JSON body instead of in a header.

No signup · No email · Ready in about a second

At a glance

Mailgun webhooks in brief

Category
Email
Where to configure
Mailgun → Send → Sending → Webhooks → Add webhook
Signature header
None — the signature travels in the body
Secret
HTTP webhook signing key (Settings → Webhooks)
Retries
Several attempts with decreasing frequency over roughly eight hours, then dropped. The Logs screen shows each attempt and the response it got.
Response deadline
Not published as a fixed number. Answer within a couple of seconds and do the work afterwards.
Treated as success
Any 2xx. Everything else, redirects included, counts as a failure and schedules a retry.

Cross-checked against Mailgun's own webhook documentation. Providers change these; if something here is stale, tell us.

The payload

What Mailgun actually sends

Shape-accurate, with obviously fake identifiers. These are the same samples the inspector's Send test webhook panel fires, so you can read one here and then send the identical bytes to your own URL.

POST/w/{your-bin-id}721 B

The signature block is inside the body. Values here are zeros.

1 headers
user-agent
mailgun/webhooks
Body · application/json · 1 line
{"signature":{"timestamp":"1750000000","token":"00000000000000000000000000000000000000000000000000","signature":"0000000000000000000000000000000000000000000000000000000000000000"},"event-data":{"event":"delivered","id":"ZmFrZS1tYWlsZ3VuLWV2ZW50LWlk","timestamp":1750000000.123456,"log-level":"info","recipient":"buyer@example.com","message":{"headers":{"to":"Ada Lovelace <buyer@example.com>","message-id":"fake.0000000000@mg.example.com","from":"Acme <receipts@example.com>","subject":"Your receipt"},"size":1234},"delivery-status":{"code":250,"message":"250 2.0.0 OK","attempt-no":1,"session-seconds":0.42},"flags":{"is-authenticated":true,"is-test-mode":false},"tags":["receipt"],"user-variables":{"order-id":"1001"}}}

No sample carries a signature header. A valid one can only be produced with your own signing secret, and a fabricated one would make the Signature tab report a mismatch that isn't real. Send a genuine delivery when you want to test verification.

Verification

Mailgun and request authenticity

Signed payload

A hex HMAC-SHA256 of the `timestamp` and `token` values concatenated, keyed with your HTTP webhook signing key. All three values — timestamp, token, and the digest — arrive inside the body's `signature` object. The body itself is not signed.

TryWebhook cannot verify this one, and the reason is structural: every verifier here reads a signature from a header and signs some function of the raw body. Mailgun puts the signature in the body and signs two other fields from the body instead. Verify it in your own code — it is four lines — and use this page to confirm the delivery, the event data, and the signature block all arrived intact.

Events

Events worth subscribing to first

Not the full catalogue — the handful that carry most integrations. Subscribing to everything is the fastest way to a handler that times out.

EventFires when
deliveredThe receiving server accepted the message. `delivery-status.code` carries the SMTP code, usually 250.
failedDelivery failed. `severity` is `permanent` or `temporary`, and only the first means you should suppress the address.
openedA tracking pixel loaded. Requires open tracking to be on, and is inflated by mail privacy proxies.
clickedA tracked link was followed. Carries the destination `url`, and needs click tracking enabled on the domain.
complainedThe recipient marked the message as spam. Mailgun adds them to the complaints list automatically; mirror that in your own database.

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.

Failure modes

Where Mailgun integrations usually break

The signature is in the body

There is no `X-Mailgun-Signature` header on modern webhooks. `timestamp`, `token`, and `signature` are top-level fields inside the JSON, which means you have to parse the body before you can verify it — the reverse of every other provider here.

What is signed is not the body

The HMAC covers `timestamp + token`, concatenated with nothing between them. The message data is not part of the signed string at all, so the signature proves the request came from Mailgun and nothing about what it says.

Which makes the token check mandatory

Because the payload is unsigned, a valid signature can be reused with a modified body. The token is 50 characters and single-use — store recently seen tokens and reject repeats, and reject timestamps older than a few minutes. Without both checks, verification is close to decorative.

The keys are hyphenated

`event-data`, `log-level`, `delivery-status`, `user-variables`. Hyphens are not valid identifiers in most languages, so these need bracket access and cannot be destructured by name. `payload.event_data` is `undefined`, quietly.

Legacy webhooks look nothing like this

Older Mailgun webhooks POSTed `application/x-www-form-urlencoded` with the fields flattened and a separate signature field. If your body is form-encoded rather than JSON, you are on the legacy format and most current documentation does not apply.

Answers

Mailgun webhook questions

Where is the Mailgun signature header?+

There is not one. Look inside the body: `signature.timestamp`, `signature.token`, and `signature.signature`. This is the main thing that surprises people porting a verifier from Stripe or Shopify.

How do I verify a Mailgun webhook?+

Concatenate `signature.timestamp` and `signature.token`, HMAC-SHA256 that string with your HTTP webhook signing key, hex-encode it, and compare with `signature.signature` in constant time. Then check the timestamp is recent and the token has not been used before.

Can TryWebhook verify it for me?+

No. The verifiers here read a signature header and hash the raw body; Mailgun does neither. You can still use this page to confirm the delivery arrived, read the event data, and see that the signature block is present and well formed.

Which signing key do I use?+

The HTTP webhook signing key, from Settings → Webhooks. It is not your API key and not your domain's sending key, and using either of those produces a mismatch that looks identical to a bug in your code.

How do I test a Mailgun webhook without sending mail?+

Each webhook in the dashboard has a Test button that POSTs a synthetic event of that type with a real signature. For genuine events, Mailgun's sandbox domain can send to your own authorised recipient addresses at no cost.

Other providers

Testing something else?

One click

Point Mailgun at a URL and watch the payload land.

Mailgun → Send → Sending → Webhooks → Add webhook — paste the URL, trigger an event, and read exactly what arrived.

No signup · No email · Ready in about a second