Test Mailgun webhooks locally
Receive Mailgun event webhooks on localhost with a Horizon tunnel, and verify the HMAC signature in a Next.js route handler.
Receive Mailgun email events on your laptop while you build, with a public HTTPS URL Mailgun can reach.
Horizon has no Mailgun integration. Mailgun posts events to a public URL, and Horizon provides that URL.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A Mailgun account with a sending domain
- A Next.js app that uses the App Router and runs on port 3000
Start your app
Mailgun doesn't put its signature in a header. Event webhooks carry a signature object in the JSON body, next to event-data, with three fields: timestamp, token and signature. The signature is the hex HMAC-SHA256 of timestamp followed by token, with no separator, keyed with your Webhook Signing Key. Mailgun ships no Node SDK helper for this check, so the handler uses node:crypto.
import { createHmac, timingSafeEqual } from "node:crypto";
type MailgunWebhook = {
signature: { timestamp: string; token: string; signature: string };
"event-data": { event: string };
};
export async function POST(request: Request) {
const signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
if (!signingKey) {
return new Response("Missing MAILGUN_WEBHOOK_SIGNING_KEY", { status: 500 });
}
const payload = (await request.json()) as MailgunWebhook;
const { timestamp, token, signature } = payload.signature;
const expected = createHmac("sha256", signingKey).update(timestamp.concat(token)).digest("hex");
const expectedBuffer = Buffer.from(expected);
const receivedBuffer = Buffer.from(signature);
const isValid =
expectedBuffer.length === receivedBuffer.length &&
timingSafeEqual(expectedBuffer, receivedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 406 });
}
console.log(`Received Mailgun event: ${payload["event-data"].event}`);
return new Response("ok", { status: 200 });
}The handler returns 406 for a bad signature. Mailgun treats 406 as a rejection and doesn't retry. For any other non-200 code, Mailgun retries.
Start the app. You add the signing key in a later step.
npm run devStart a tunnel
In a second terminal, open a tunnel on a subdomain you reserved.
hrzn tunnel http://localhost:3000 -s my-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour public URL is https://my-app.hrzn.run. Keep this terminal open.
Use -s. Without it, the subdomain is random and changes every run, so your Mailgun webhook would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.
Add the webhook in Mailgun
Mailgun configures webhooks per event type, at the domain level or the account level. Each event type takes up to 3 URLs.
- In the Mailgun Control Panel, open Webhooks for your domain.
- Select Add webhook.
- Choose an event type, for example
delivered. - Set the URL to
https://my-app.hrzn.run/api/webhooks/mailgun. - Select Create Webhook.
Then copy your signing key. Open Settings, then API Security, and copy the HTTP webhook signing key. It is not your API key or your domain sending key.
MAILGUN_WEBHOOK_SIGNING_KEY=replace-with-your-signing-keyRestart npm run dev so Next.js loads the variable.
Mailgun's US and EU regions are separate. Create the webhook in the region that holds your domain.
Trigger an event
Send an email through your Mailgun domain the way your app normally does. When Mailgun delivers it, a delivered event reaches your URL, if you chose that event type.
Check it works
The Horizon terminal prints one line per request:
POST 200 /api/webhooks/mailgunYour app terminal prints:
Received Mailgun event: deliveredTroubleshooting
The route returns 406
The Horizon line shows [406]. The signature check failed.
- Check that
MAILGUN_WEBHOOK_SIGNING_KEYis the HTTP webhook signing key from Settings, API Security. An API key or a sending key never matches. - Restart
npm run devafter you edit.env.local. - If the event comes from a subaccount, the signature object also has a
parent-signaturefield, computed with the primary account's signing key.
The route throws on payload.signature
The handler expects the JSON shape Mailgun documents for signed event webhooks: a signature object and an event-data object. Log await request.text() once to see what your account sends. Mailgun's Routes forward() and store() posts use form fields instead of JSON.
Mailgun keeps retrying
Mailgun retries on any code other than 200 and 406, with growing intervals over 8 hours. Check that your handler returns 200 and doesn't throw.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.
Next steps
- Read Mailgun's guide to securing webhooks.
- See Pricing to reserve a subdomain so your URL never changes.
Test Mailchimp webhooks locally
Receive Mailchimp audience webhooks on localhost with a Horizon tunnel, answer the URL check, and verify the X-Mailchimp-Signature header.
Test Microsoft Teams outgoing webhooks locally
Receive Microsoft Teams outgoing webhook requests on localhost with a Horizon tunnel, and verify the HMAC authorization header.