Horizon

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.

Receive Mailchimp audience events on your laptop while you build, with a public HTTPS URL Mailchimp can reach.

Horizon has no Mailchimp integration. Mailchimp posts audience changes 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 Mailchimp account with an audience
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Mailchimp sends audience events as a POST with a form-encoded body (application/x-www-form-urlencoded). The body has a type field, such as subscribe, and a data object with the subscriber.

Mailchimp can sign each delivery. When it does, the X-Mailchimp-Signature header looks like t=<timestamp>,v1=<hex signature>. The signature is the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the signing secret Mailchimp shows once when you save the webhook. Mailchimp says to reject deliveries with a timestamp older than 5 minutes. Signing is optional in Mailchimp, but the handler below requires it. Mailchimp ships no Node SDK helper for this check, so the handler uses node:crypto.

The handler also answers GET with 200. The Mailchimp step below explains why.

app/api/webhooks/mailchimp/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export async function GET() {
  return new Response("ok", { status: 200 });
}

export async function POST(request: Request) {
  const signingSecret = process.env.MAILCHIMP_WEBHOOK_SIGNING_SECRET;
  if (!signingSecret) {
    return new Response("Missing MAILCHIMP_WEBHOOK_SIGNING_SECRET", { status: 500 });
  }

  const rawBody = await request.text();
  const header = request.headers.get("x-mailchimp-signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
  const timestamp = parts.t;
  const received = parts.v1;

  if (!timestamp || !received) {
    return new Response("Missing signature", { status: 401 });
  }

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(ageSeconds <= TOLERANCE_SECONDS)) {
    return new Response("Timestamp outside the tolerance window", { status: 401 });
  }

  const expected = createHmac("sha256", signingSecret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const expectedBuffer = Buffer.from(expected);
  const receivedBuffer = Buffer.from(received);
  const isValid =
    expectedBuffer.length === receivedBuffer.length &&
    timingSafeEqual(expectedBuffer, receivedBuffer);

  if (!isValid) {
    return new Response("Invalid signature", { status: 401 });
  }

  const type = new URLSearchParams(rawBody).get("type");
  console.log(`Received Mailchimp event: ${type}`);

  return new Response("ok", { status: 200 });
}

Mailchimp cancels a request that takes more than 10 seconds, then retries at growing intervals over 75 minutes. Keep the handler fast.

Start the app. You add the signing secret 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 Mailchimp webhook would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.

Add the webhook in Mailchimp

  1. In Mailchimp, open Audience.
  2. In the Current Audience dropdown, select the audience you want.
  3. Open the Manage Audience dropdown and select Settings.
  4. Select Webhooks, then Create New Webhook.
  5. Set the callback URL to https://my-app.hrzn.run/api/webhooks/mailchimp.
  6. Select the boxes for the events you want, for example Subscribes and Unsubscribes.
  7. Select Save.

After you save, Mailchimp shows a signing secret once. Copy it before you close the dialog. If you lose it, delete the webhook and create a new one.

.env.local
MAILCHIMP_WEBHOOK_SIGNING_SECRET=replace-with-the-signing-secret

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

Trigger an event

Mailchimp has no resend button. Add a test subscriber instead.

  1. Open Audience and select your audience.
  2. In the Add Contacts dropdown, select Add a Subscriber.
  3. Fill in the fields with a test email address.
  4. Select the checkbox This person gave me permission to email them, to skip the confirmation email.
  5. Select Subscribe.

Check it works

The Horizon terminal prints a GET line for the URL check and a POST line for the event:

Output
  GET     200  /api/webhooks/mailchimp
  POST    200  /api/webhooks/mailchimp

Your app terminal prints:

Output
Received Mailchimp event: subscribe

If you don't need signature checks, Mailchimp's other documented protection is a hard-to-guess secret in the callback URL that your handler checks. Use HTTPS either way.

Troubleshooting

Mailchimp won't save the webhook

Start npm run dev and the tunnel first. Open https://my-app.hrzn.run/api/webhooks/mailchimp in a browser. You should see ok. If the Horizon line shows [404], the route file is missing or in the wrong folder.

The route returns 401

  • Missing signature: the request has no X-Mailchimp-Signature header. Signing is optional in Mailchimp. Delete the webhook and create it again, then copy the signing secret from the dialog.
  • Timestamp outside the tolerance window: the timestamp is more than 5 minutes off. Check your machine's clock.
  • Invalid signature: check that MAILCHIMP_WEBHOOK_SIGNING_SECRET matches the secret Mailchimp showed, and restart npm run dev after editing .env.local. Mailchimp signs the raw body. Don't parse or decode it before the check.

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 Mailchimp's guide to webhooks.
  • See Pricing to reserve a subdomain so your URL never changes.

On this page