Skip to content
Last updated

Securing Webhooks

Mailgun signs every HTTP POST it makes to your servers using your account's Webhook Signing Key. This applies to:

  • Event webhooks – delivered, opened, clicked, unsubscribed, complained, etc.
  • Inbound Routes – the POST made to your URL by a forward() or store(notify=...) action.

The signing key is account-wide: a single key covers every domain and both webhook types. It is not your API key or your domain sending key. You can view or rotate it in the Mailgun Control Panel under Settings → API Security → HTTP webhook signing key.

Applies to: event webhooks and Routes forward()/store() posts. Verify both with the same key and the same algorithm described below.

Signature format

Each signed POST carries three values: token, timestamp, and signature.

Event webhooks deliver them as a JSON object named signature, alongside event-data:

{
    "token": "e0b5477167110d68991efc6b9f89f0a11066af27834600e123",
    "timestamp": "1770920772",
    "signature": "12d99f5a15355c180971bed7494d578b093c958f57766f3fe750761baed12345"
}

Routes posts (forward() / store()) deliver the same three values as top-level fields in the multipart/form-data or application/x-www-form-urlencoded body, next to the message fields (sender, recipient, body-plain, etc.):

timestamp=1770920772
token=e0b5477167110d68991efc6b9f89f0a11066af27834600e123
signature=12d99f5a15355c180971bed7494d578b093c958f57766f3fe750761baed12345

The values and the verification algorithm are identical in both cases; only the wrapping differs.

Subaccounts

When an event occurs for a domain belonging to a subaccount, the signature payload will include a parent-signature field to identify the primary account. This is so that the receiving server does not have to account for each individual subaccount signature and can instead reference one primary/parent signature to verify the authenticity of the webhook.

Example:

{
    "token": "e0b5477167110d68991efc6b9f89f0a11066af27834600e123",
    "timestamp": "1770920772",
    "signature": "12d99f5a15355c180971bed7494d578b093c958f57766f3fe750761baed12345",
    "parent-signature": "13d99f5a15355c180971bed7494d578b093c958f57766f3fe750761baed54321"
}
ParameterTypeDescription
tokenstringRandomly generated string with length of 50.
timestampintNumber of seconds passed since January 1, 1970.
signaturestringHex-encoded HMAC-SHA256 of timestamp + token, keyed with your Webhook Signing Key.
parent-signaturestringSame as signature, computed with the primary account's Webhook Signing Key; only included when the event happens on a subaccount.

Verifying a signature

The steps are the same whether the POST is an event webhook or a Routes forward()/store() post:

  • Concatenate the timestamp and token values together with no separator.
  • Compute an HMAC of the resulting string using SHA256, with your Webhook Signing Key as the key. Use the signing key from Settings → API Security, not your API key.
  • Compare the resulting hexdigest to the signature value. If they do not match, the request is not from Mailgun — reject it.
  • Optionally, cache the token value locally and do not honor any subsequent request with the same token. This prevents replay attacks.
  • Optionally, check that the timestamp is not too far from the current time.
    • There can be delays in webhook processing that are outside of Mailgun's control, so don't be too aggressive with this check.

Here is a sample NodeJS snippet that checks the signature. It works unchanged for both event webhooks and Routes posts — just pass in the timestamp, token, and signature values from wherever they appear in the request body:

const crypto = require('crypto')

const verify = ({ signingKey, timestamp, token, signature }) => {
    const encodedToken = crypto
        .createHmac('sha256', signingKey)
        .update(timestamp.concat(token))
        .digest('hex')

    return (encodedToken === signature)
}

// Event webhook: values are under payload.signature
// verify({ signingKey, ...payload.signature })

// Routes forward()/store() post: values are top-level form fields
// verify({ signingKey, timestamp: form.timestamp, token: form.token, signature: form.signature })

TLS Client Authentication

If your receiving server uses a valid TLS configuration, Mailgun includes a TLS client certificate with its webhook requests. This allows you to perform transport-level validation by inspecting the certificate presented in the request, validating the root CA and the certificate's Common Name, as an additional check that the request originated from Mailgun.

How it works:

  • Mailgun includes its TLS client certificate in webhook requests. The certificate is owned and managed by Mailgun and issued by DigiCert.
  • Configure your server to accept requests only from clients presenting a valid TLS certificate issued by a trusted CA (e.g., DigiCert).
  • Once you've confirmed the certificate is valid, verify that its Common Name (CN) is webhooks.mgsend.net.

Warning: Mailgun does not support validating server certificates issued by custom CAs on Mailgun's side before sending (i.e., mutual TLS / mTLS).

You can download the current certificate for inspection from the US or EU API.