Horizon

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.

app/api/webhooks/mailgun/route.ts
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 dev

Start a tunnel

In a second terminal, open a tunnel on a subdomain you reserved.

hrzn tunnel http://localhost:3000 -s my-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Your 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.

  1. In the Mailgun Control Panel, open Webhooks for your domain.
  2. Select Add webhook.
  3. Choose an event type, for example delivered.
  4. Set the URL to https://my-app.hrzn.run/api/webhooks/mailgun.
  5. 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.

.env.local
MAILGUN_WEBHOOK_SIGNING_KEY=replace-with-your-signing-key

Restart npm run dev so Next.js loads the variable.

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:

Output
  POST    200  /api/webhooks/mailgun

Your app terminal prints:

Output
Received Mailgun event: delivered

Troubleshooting

The route returns 406

The Horizon line shows [406]. The signature check failed.

  • Check that MAILGUN_WEBHOOK_SIGNING_KEY is the HTTP webhook signing key from Settings, API Security. An API key or a sending key never matches.
  • Restart npm run dev after you edit .env.local.
  • If the event comes from a subaccount, the signature object also has a parent-signature field, 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

On this page