All articles
Test Mailgun Webhooks on Localhost
Mailgunemail webhooksHMAC verificationlocalhost

Test Mailgun Webhooks on Localhost

To test Mailgun webhooks on localhost, expose your local handler with npx portpreview PORT, configure the resulting HTTPS endpoint for the required Mailgun event types, and verify the payload's timestamp, token, and HMAC-SHA256 signature before accepting the event.

What Mailgun webhooks report

Mailgun sends an HTTP or HTTPS POST with a JSON payload when a configured event occurs. Current event types include accepted, delivered, temporary_fail, permanent_fail, opened, clicked, spam complaints, and unsubscribes. Tracking-dependent events only appear when the corresponding tracking is enabled.

A current Mailgun Send webhook body has a signature object alongside event-data. The event data contains fields such as event, id, timestamp, message headers, recipient information, tags, and delivery details, depending on the event type. Code against documented fields and tolerate absent optional properties. Mailgun's official payload examples are the best fixtures for contract tests.

Do not confuse a Mailgun Send webhook with Mailgun Alerts. Alerts use a different signing key and sign the entire POST body into an X-Sign header. This guide covers Send webhooks: the signature fields in the payload and the account's Webhook Signing Key.

1. Build a local Mailgun endpoint

Unlike schemes that sign the raw JSON body, Mailgun Send's documented calculation uses the signature object's timestamp and token. Standard JSON parsing is therefore appropriate. The following Express handler verifies HMAC, performs a replay-age check, and durably accepts the event.

import crypto from 'node:crypto';
import express from 'express';

const app = express();
app.use(express.json({ limit: '1mb' }));

function verifyMailgunSignature({ timestamp, token, signature }) {
  if (!timestamp || !token || !signature) return false;

  const expected = crypto
    .createHmac('sha256', process.env.MAILGUN_WEBHOOK_SIGNING_KEY)
    .update(String(timestamp) + String(token))
    .digest('hex');

  const expectedBytes = Buffer.from(expected, 'hex');
  const actualBytes = Buffer.from(String(signature), 'hex');
  return expectedBytes.length === actualBytes.length &&
    crypto.timingSafeEqual(expectedBytes, actualBytes);
}

app.post('/webhooks/mailgun', async (req, res) => {
  const signing = req.body?.signature;
  const event = req.body?.['event-data'];

  if (!signing || !event || !verifyMailgunSignature(signing)) {
    return res.status(406).send('invalid webhook');
  }

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(signing.timestamp));
  if (!Number.isFinite(ageSeconds) || ageSeconds > 15 * 60) {
    return res.status(406).send('stale webhook');
  }

  await acceptOnce({
    eventId: event.id,
    replayToken: signing.token,
    payload: event,
  });
  return res.sendStatus(200);
});

app.listen(3000);

The 15-minute window is an application policy, not a Mailgun-mandated value. Mailgun recommends checking that the timestamp is not too far from the current time but warns against being overly aggressive because delivery can be delayed. Choose a window that fits your queueing and incident-recovery requirements, monitor legitimate rejections, and adjust it deliberately.

Store the Webhook Signing Key in a secret manager or environment variable, never in source control. Mailgun's securing webhooks guide defines the exact calculation: concatenate timestamp and token with no separator, compute HMAC-SHA256 using the Webhook Signing Key, and compare the hexadecimal digest with signature.

2. Expose localhost over HTTPS

With the application listening on port 3000, run:

npx portpreview 3000

Append the local route to the public HTTPS origin. For example:

https://example.portpreview.dev/webhooks/mailgun

Leave both the application and tunnel running during the test. Mailgun needs a publicly reachable URL; localhost, a private LAN address, and a self-signed development certificate are not suitable remote destinations. PortPreview terminates public HTTPS and forwards the request to your local port.

3. Configure Mailgun event URLs

Mailgun supports account-level and domain-level webhook configuration. Account-level endpoints can receive events across domains and inherited subaccounts; domain-level endpoints apply only to that domain. Each event type is configured individually and can have up to three URLs. Select the narrowest scope that matches your application.

  1. Open the Webhooks area for the intended account or sending domain.
  2. Choose an event type, such as delivered or permanent_fail.
  3. Add the full PortPreview HTTPS endpoint.
  4. Repeat for each event type your handler supports.
  5. Send a test or real message and inspect the local request and application logs.

Mailgun deduplicates the same URL for the same event when it is configured at both account and domain levels, but different URLs can each receive a copy. Parent-account inheritance can also cause deliveries to multiple distinct endpoints. Review the official configuration rules before attributing every extra delivery to retries.

How Mailgun signature verification works

The signature object contains:

  • timestamp: Unix time in seconds.
  • token: a randomly generated 50-character string.
  • signature: a hexadecimal HMAC digest.
  • parent-signature: optionally present for an event from a subaccount, allowing validation against the primary account relationship described by Mailgun.

For the normal account signature, calculate HMAC-SHA256(signingKey, timestamp + token). There is no separator and the event-data JSON is not part of this documented Mailgun Send calculation. Compare decoded bytes with a timing-safe function after checking equal lengths. A plain === comparison is simpler, but a timing-safe comparison is the safer production default.

An authentic HMAC proves that a party holding the signing key produced the signature. It does not prove that this delivery has not been replayed. Mailgun specifically recommends caching the token and rejecting a subsequent request with the same token. A timestamp-age check limits how long a captured valid request remains useful. Use both controls: a unique token constraint for replay and a reasonable time window for freshness.

Deduplicate both deliveries and effects

Keep two durable uniqueness constraints: one for the signature token and one for the Mailgun event-data.id. The token catches an identical signed delivery replay. The event ID protects business logic if the same event appears in another valid delivery context. Namespace both by provider and account or environment.

async function acceptOnce({ eventId, replayToken, payload }) {
  await db.transaction(async (tx) => {
    const tokenWasNew = await tx.webhookTokens.insertIfAbsent({
      provider: 'mailgun',
      token: replayToken,
    });
    if (!tokenWasNew) return;

    const eventWasNew = await tx.webhookEvents.insertIfAbsent({
      provider: 'mailgun',
      eventId,
      receivedAt: new Date(),
    });
    if (!eventWasNew) return;

    await tx.jobs.enqueue({
      type: 'process-mailgun-event',
      payload,
    });
  });
}

Back the insert-if-absent operations with database unique indexes; a read followed by an insert is race-prone under concurrent deliveries. Commit the dedup records and queue job atomically. Then acknowledge quickly and let a worker update message state, trigger alerts, or synchronize a CRM. See the retry and idempotency guide for alternatives when the queue and business database cannot share a transaction.

Mailgun response codes and retry behavior

Mailgun's current Send webhook documentation gives three important outcomes:

  • 200 Success: Mailgun treats the webhook POST as successful and does not retry it.
  • 406 Not Acceptable: Mailgun treats the POST as rejected and does not retry it.
  • Any other code: for webhooks other than delivery notifications, Mailgun retries over eight hours at 5 minutes, 10 minutes, 15 minutes, 1 hour, 2 hours, and 4 hours.

The delivery-notification exception matters: do not promise that every event type follows the general retry schedule. Check the latest automatic retries documentation when delivery guarantees affect your design.

Use 406 only for a request you intentionally reject permanently, such as an invalid signature or a replay outside policy. Use 500 or 503 for transient database and queue failures so eligible webhook types can retry. Return 200 only after durable acceptance. Returning 200 while starting untracked background work can lose the event if the process exits.

Troubleshooting Mailgun webhooks locally

The computed HMAC never matches

Confirm that you are using the Webhook Signing Key, not an API key, SMTP password, or Alerts signing key. Concatenate the signature object's timestamp and token with no delimiter. Produce a lowercase hexadecimal SHA-256 digest. Also verify that your framework has not renamed the hyphenated event-data property; bracket notation avoids that mistake.

The handler receives form fields instead of current JSON

Check which Mailgun feature and endpoint version generated the request. Do not apply a legacy payload tutorial blindly to a current Send webhook. Log content type, top-level field names, and body length in development without logging message contents or secrets, then implement the documented contract for your account and integration.

Mailgun keeps retrying

Inspect the actual status sent over the wire. An exception after database commit may turn the response into 500, causing another attempt. That is why the event ID and token inserts must be unique and durable. If a request is permanently invalid, return 406; if the failure is transient, fix the service and allow retry behavior to work.

No event reaches localhost

Confirm the URL is attached to the correct account or domain and to the exact event type being produced. A delivered URL will not receive opened events. Check that the local process and tunnel are still active and that the configured path is /webhooks/mailgun. Follow the local webhook debugging guide to separate provider configuration from routing and application errors.

Security checklist

  • Verify HMAC before trusting or logging event-data.
  • Keep the signing key in a secret store and rotate it through a controlled deployment; never expose it in client-side code.
  • Use timing-safe digest comparison, a timestamp policy, and a durable unique constraint on the token.
  • Validate the event type and required fields before enqueueing. Treat recipient addresses, subjects, storage URLs, and user variables as sensitive data.
  • Accept only POST, cap body size, use HTTPS, and rate-limit failures without blocking legitimate Mailgun retries.
  • Do not expose unrelated local admin or debug endpoints through the temporary public origin.
  • When testing ends, remove the temporary URL and configure the stable production endpoint.

Mailgun also documents an optional TLS client certificate on webhook requests when your receiving server has valid TLS. That can provide transport-level validation, but it does not replace payload HMAC verification, replay controls, and application authorization. Layer controls according to your threat model.

Production acceptance tests

  1. Deliver a valid signed fixture and confirm one durable event plus a 200 response.
  2. Change the token without changing the signature and confirm a 406 with no event write.
  3. Replay the exact valid body and confirm no second job or side effect.
  4. Send a valid signature with a timestamp outside your configured window and verify the intended rejection.
  5. Force a temporary database error, confirm a non-200/non-406 response, then restore the database and verify one successful acceptance.
  6. Exercise each configured Mailgun event type because payload fields and retry expectations differ.

Once those tests pass, use the same verification and deduplication path in production. For a provider-independent explanation of HMAC comparison and secret handling, read the webhook signature verification guide.

Frequently asked questions

Can Mailgun send webhooks to localhost?
Mailgun cannot reach localhost directly. Run `npx portpreview PORT`, append your webhook route to the generated HTTPS origin, and configure that public URL for each required Mailgun event type.
How do I verify a Mailgun Send webhook signature?
Concatenate the payload signature object's timestamp and token with no separator, calculate an HMAC-SHA256 hexadecimal digest using the Webhook Signing Key, and compare it with the supplied signature using a timing-safe comparison.
How do I prevent Mailgun webhook replay attacks?
Store each signature token under a durable unique constraint and reject a token already seen. Also enforce a reasonable timestamp-age policy, allowing enough time for legitimate delivery delays and your operating requirements.
When does Mailgun retry a failed webhook?
Mailgun treats 200 as success and 406 as a permanent rejection. For other responses, webhooks other than delivery notifications use the documented retry intervals over about eight hours, so handlers must be idempotent.