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.
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 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 Mailchimp webhook would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.
Add the webhook in Mailchimp
- In Mailchimp, open Audience.
- In the Current Audience dropdown, select the audience you want.
- Open the Manage Audience dropdown and select Settings.
- Select Webhooks, then Create New Webhook.
- Set the callback URL to
https://my-app.hrzn.run/api/webhooks/mailchimp. - Select the boxes for the events you want, for example Subscribes and Unsubscribes.
- Select Save.
Mailchimp checks a new callback URL with a GET request before it accepts it. Mailchimp's own guide doesn't describe this check, but other guides report it. The handler answers GET with 200, so keep npm run dev and the tunnel running when you save. If saving fails, see Troubleshooting.
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.
MAILCHIMP_WEBHOOK_SIGNING_SECRET=replace-with-the-signing-secretRestart npm run dev so Next.js loads the variable.
Trigger an event
Mailchimp has no resend button. Add a test subscriber instead.
- Open Audience and select your audience.
- In the Add Contacts dropdown, select Add a Subscriber.
- Fill in the fields with a test email address.
- Select the checkbox This person gave me permission to email them, to skip the confirmation email.
- Select Subscribe.
Check it works
The Horizon terminal prints a GET line for the URL check and a POST line for the event:
GET 200 /api/webhooks/mailchimp
POST 200 /api/webhooks/mailchimpYour app terminal prints:
Received Mailchimp event: subscribeIf 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 noX-Mailchimp-Signatureheader. 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 thatMAILCHIMP_WEBHOOK_SIGNING_SECRETmatches the secret Mailchimp showed, and restartnpm run devafter 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
Test Intercom webhooks locally
Receive Intercom webhook notifications on localhost with a Horizon tunnel, answer the HEAD validation request, and verify X-Hub-Signature.
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.