Mailgun Webhook: Example Payload, Headers and Signature

Mailgun puts its signature inside the JSON body instead of in a header, and that signature covers a timestamp and a token rather than the event itself. Here is a delivered event and what that design means for verification.

No webhook URL yet. Creating one needs no account.

Request details
Requests sent to your webhook URL appear here: method, headers and the body exactly as it arrived.

The button posts the example below to your webhook URL from your browser. It is not sent by Mailgun, it carries your browser's User-Agent, and its signature is a placeholder that will not verify. To see a real delivery, follow the steps further down.

Last updated . How this page is checked.

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

HeaderExampleWhat it is
Content-Typeapplication/jsonThe 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

  1. Press Create webhook URL at the top of this page and copy the address.
  2. In the Mailgun dashboard, open Sending → Webhooks and pick a domain. The sandbox domain is fine.
  3. Add a webhook, choose an event type such as delivered, and paste the address as the URL.
  4. Use the dashboard's test option to send a sample payload, or send a message through the domain.
  5. 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-data is 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

Other webhooks with a timestamp in the signature

References

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.