How the webhook URL works. It is a public address that records whatever is sent to it and
answers 200. An inbox keeps the 50 most recent requests, accepts bodies up to 256 KB, and is
deleted 24 hours after it was created — or sooner if you press Delete URL or our server
restarts, because requests are held in memory and never written to disk.
Anyone who has the URL can read what it received. Use sandbox and test-mode accounts, do not route production traffic or customer data through it, and delete it when you are done. Privacy details.
What is a Mailgun webhook?
A Mailgun webhook is an HTTP POST sent to a URL registered on a sending domain when something happens to a message: it is accepted, delivered, opened, clicked, fails temporarily or permanently, is marked as spam, or the recipient unsubscribes. You register a URL per event type, and one event type can have several URLs.
The JSON body has two top-level members. signature holds the three values used to authenticate the request, and event-data holds the event: its type, the recipient, the message headers, any tags and custom variables you attached when sending, and delivery details such as the receiving server's response.
What a Mailgun webhook request looks like
The request line and headers as they reach your server, followed by the body. Ids, tokens and the signature are placeholders.
POST /your/webhook/path HTTP/1.1
Host: your-server.example
Content-Type: application/json
{
"signature": {
"token": "4b1a9d0c7e6f5a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a",
"timestamp": "1767225600",
"signature": "5d0e3b1c8f7a6d5e4b3c2a1f0e9d8c4f8b1e0c7a2d9e6b3c5f0a1d8e7b6c4f2a"
},
"event-data": {
"id": "CPgfbmQMTCKtHW6uIWtuVe",
"event": "delivered",
"timestamp": 1767225600.123456,
"log-level": "info",
"recipient": "ada@example.com",
"recipient-domain": "example.com",
"message": {
"headers": {
"message-id": "20260101120000.1.EXAMPLE@mg.your-domain.example",
"from": "Billing <billing@mg.your-domain.example>",
"to": "ada@example.com",
"subject": "Your receipt"
},
"size": 2412
},
"delivery-status": {
"code": 250,
"message": "OK",
"attempt-no": 1,
"session-seconds": 0.42,
"tls": true
},
"tags": [
"receipts"
],
"user-variables": {
"order_id": "6735"
}
}
}
Many keys contain hyphens, so they need bracket access in JavaScript: body['event-data']['delivery-status']. The signature's timestamp is a string of whole seconds, while event-data.timestamp is a number with a fractional part. Anything you sent as a custom variable comes back under user-variables, which is the reliable way to tie an event to a record in your own system.
Headers Mailgun sends
| Header | Example | What it is |
|---|---|---|
Content-Type | application/json | The body is JSON. Authentication data is in the body, not in a header. |
Header names are case-insensitive. Your framework may show them lower-cased, and HTTP/2 always does.
How Mailgun signs a webhook
Mailgun concatenates the timestamp and the token from the body's signature object, with nothing between them, computes an HMAC-SHA256 with your account's HTTP webhook signing key, and places the hex digest in signature.signature. The signing key is its own value in the dashboard; it is not the API key.
Notice what is missing: the event data is not part of the signed string. A valid signature proves that Mailgun produced that timestamp and token, not that the rest of the body is untouched. That makes two extra checks worthwhile. Reject timestamps that are not recent, and remember each token for a while and refuse to accept it twice, so a captured signature cannot be attached to a different event.
const crypto = require('crypto');
// sig: the "signature" object from the parsed JSON body
function verifyMailgun(sig, signingKey, toleranceSec = 300) {
const { timestamp, token, signature } = sig || {};
if (!timestamp || !token || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', signingKey)
.update(timestamp + token).digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Response codes and retries
Mailgun reads your status code as an instruction. A 200 means success. A 406 means "do not retry": Mailgun records the rejection and stops. Any other status, or no response, is treated as a temporary failure and retried at widening intervals over about eight hours, per Mailgun's documentation at the time of writing, after which the event is dropped.
That makes 406 the right answer for a request you will never accept, such as one whose signature is wrong, and a 5xx the right answer when your own database is down and you want the event again later. Several events for one message are independent deliveries and can arrive in any order.
Catch a real Mailgun webhook
- Press Create webhook URL at the top of this page and copy the address.
- In the Mailgun dashboard, open Sending → Webhooks and pick a domain. The sandbox domain is fine.
- Add a webhook, choose an event type such as delivered, and paste the address as the URL.
- Use the dashboard's test option to send a sample payload, or send a message through the domain.
- Read the JSON in the inbox above. The signature object is at the top of the body.
To run a caught request against your own code, select it and press Open in API Tester: the method, headers and body are loaded into the request console, where you enter your server's public URL and send it. A body you edit will no longer match its signature — that is the signature doing its job.
Things that catch people out
- The signature does not cover the event. Do not treat a valid signature as proof that
event-datais unmodified; use HTTPS and one-time tokens. - Legacy webhooks are form-encoded. Older integrations receive the same information as form fields, with the signature values as top-level parameters.
- Opens and clicks are estimates. Image proxies and link scanners generate them without a human; do not drive business logic from a single open.
api-tester.com is independent and is not affiliated with Mailgun. Providers change header names, limits and retry schedules; treat the official Mailgun documentation as the source of truth.
Related examples and tools
Typeform Webhook Example
A Typeform webhook sends one form submission: the questions as they were defined and the answers as they were given, in two separate arrays you have to join yourself.
Calendly Webhook Example
Calendly webhooks are created through the API rather than a settings page, and they describe everything by URI.
Linear Webhook Example
A Linear webhook reports a change to one entity — an issue, a comment, a project — and, on updates, tells you the previous values of whatever changed.
Clerk Webhook Example
Clerk delivers its webhooks through Svix, so the three svix- headers and their signing scheme are shared with every other service that uses the same infrastructure.
Paddle Webhook Example
Paddle Billing notifications look a lot like Stripe's until you parse the signature header, which uses different separators and joins the timestamp to the body differently.
SendGrid Webhook Example
Email events from another provider: batched in an array and signed with a public key.
Curl POST JSON
Send a JSON body like this one from a terminal.
Webhook Tester
The inbox on its own page, plus a guide to signatures, retries and idempotent handlers.
All Webhook Examples
30 providers side by side: content type, signature scheme and retry behaviour.
Other webhooks with a timestamp in the signature
References
- Official Mailgun documentation — The provider's own guide to the requests described on this page.
- Mailgun documentation: securing webhooks — Verifying the timestamp, token and signature.
- Mailgun documentation: events — The event types a webhook can report.
- RFC 2104: HMAC — The keyed-hash construction this signature is built on.
- Node.js: crypto.timingSafeEqual() — The constant-time comparison used in the code on this page.
These links leave api-tester.com, which is independent of the companies and projects they point to.
Mailgun webhook FAQ
What does a Mailgun webhook payload look like?
A JSON object with two members: signature, containing timestamp, token and signature, and event-data, containing the event type, recipient, message headers, delivery status, tags and user variables.
How do I verify a Mailgun webhook?
Concatenate signature.timestamp and signature.token, compute an HMAC-SHA256 with your HTTP webhook signing key, hex-encode it and compare with signature.signature. Also reject old timestamps and reused tokens.
Which header carries the Mailgun signature?
None. Mailgun sends the timestamp, token and signature inside the JSON body, in the signature object.
Does Mailgun retry failed webhooks?
Yes, unless your endpoint answers 406. Other non-200 responses are retried at increasing intervals for about eight hours according to Mailgun's documentation at the time of writing.
What is the Mailgun webhook signing key?
A secret shown in the Mailgun dashboard specifically for verifying webhooks. It is separate from the private API key used to send mail.