Gareth Wilson Gareth Wilson

Guide to Mailchimp Webhooks: Features and Best Practices

Published


Mailchimp webhooks notify your application when contacts subscribe, unsubscribe, update their profile, change their email, get cleaned, or when a campaign finishes sending. If you're syncing audience changes to another system, webhooks are how you react without polling.

This guide covers how Mailchimp webhooks work, the events you'll handle, why there's no signature to verify (and what to do instead), and the best practices for production.

What are Mailchimp webhooks?

Mailchimp webhooks are HTTP POSTs delivered as application/x-www-form-urlencoded (not JSON) to a URL you register per audience. Mailchimp does not sign its webhooks: there's no HMAC, no signature header, and no shared signing secret to compute against. Authenticity instead rests on two things you control: a GET URL-validation handshake, and an unguessable ?secret= query parameter you compare on every POST.

Mailchimp webhook features

FeatureDetails
ConfigurationAudience > Settings > Webhooks (per audience/list)
VerificationNo signature; verify an unguessable ?secret= query param (timing-safe)
Setup handshakeMailchimp sends a GET on save; return 200 or it won't save
Payloadapplication/x-www-form-urlencoded, with bracket-notation nesting (data[merges][FNAME])
TimeoutRespond within ~10 seconds
SDKNone

Common events

Mailchimp dispatches on a top-level type field. There are six event types:

typeFires when
subscribeA contact joins the audience
unsubscribeA contact leaves (action is unsub or delete; reason manual or abuse)
profileA contact updates their profile
upemailA contact changes their email address
cleanedAn address is cleaned (reason hard bounce or abuse)
campaignA campaign finishes sending

Nested fields arrive as bracket-notation form keys (data[merges][FNAME]), so expand them before reading.

Setting up Mailchimp webhooks

In the Mailchimp dashboard, go to Audience > Settings > Webhooks > Create New Webhook. Because Mailchimp doesn't provide a signing secret, you generate one yourself (for example openssl rand -hex 32), store it as MAILCHIMP_WEBHOOK_SECRET, and put it in the callback URL as a query parameter:

https://your.app/webhooks/mailchimp?secret=<MAILCHIMP_WEBHOOK_SECRET>

On save, Mailchimp immediately sends a GET to that URL, your endpoint must return 200 or the webhook won't save.

Securing Mailchimp webhooks

There's no signature, so security rests on transport plus a shared secret. Answer the GET handshake with 200 (don't gate it on the secret, it's a liveness check). On each POST, compare the incoming secret query param against your stored value with a constant-time comparison, and serve the endpoint over HTTPS so the secret isn't exposed in transit.

const crypto = require("crypto");

const SECRET = process.env.MAILCHIMP_WEBHOOK_SECRET;

function verifySecret(provided) {
  if (!provided || !SECRET) return false;
  const a = Buffer.from(provided);
  const b = Buffer.from(SECRET);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// The URL-validation ping is a GET: return 200 so the webhook saves.
app.get("/webhooks/mailchimp", (req, res) => res.sendStatus(200));

// Event delivery is a form-encoded POST. extended:true nests data[merges][FNAME].
app.post("/webhooks/mailchimp", express.urlencoded({ extended: true }), (req, res) => {
  if (!verifySecret(req.query.secret)) return res.sendStatus(401);

  res.sendStatus(200); // acknowledge fast
  const { type, data = {} } = req.body || {};
  processQueue.add({ type, data }); // branch on type, async
});

The same checks in Python (FastAPI):

import hmac
import os
from fastapi import FastAPI, Request, Response

app = FastAPI()
SECRET = os.environ.get("MAILCHIMP_WEBHOOK_SECRET", "")

def verify_secret(provided: str) -> bool:
    if not provided or not SECRET:
        return False
    return hmac.compare_digest(provided, SECRET)

@app.get("/webhooks/mailchimp")
async def validate():
    return Response("OK", status_code=200)

@app.post("/webhooks/mailchimp")
async def webhook(request: Request):
    if not verify_secret(request.query_params.get("secret", "")):
        return Response("Unauthorized", status_code=401)
    form = await request.form()  # application/x-www-form-urlencoded
    enqueue(dict(form))  # branch on form["type"], async
    return Response("OK", status_code=200)

Mailchimp webhook limitations and pain points

There's no signature to verify

The Problem: Mailchimp doesn't sign webhooks, so you can't prove cryptographically that a POST came from Mailchimp. Anyone who learns your URL and secret can post events, and the payload itself is unauthenticated.

Why It Happens: Mailchimp never shipped webhook signatures.

Workarounds:

  • Verify the unguessable ?secret= in constant time, serve HTTPS only, keep the secret out of logs, and validate the payload shape.

How Hookdeck Can Help: This is where a gateway earns its place. Hookdeck sits in front of Mailchimp and adds the verification, filtering, deduplication, and delivery observability the source itself can't provide, turning an unsigned, fire-and-forget webhook into a monitored, replayable stream.

The secret travels in the URL

The Problem: Because the secret is a query parameter, it can leak through access logs, proxies, and browser history.

Why It Happens: Mailchimp's only shared secret is the one you append to the URL.

Workarounds:

  • Use HTTPS, scrub query strings from logs, and rotate the secret if exposed.

How Hookdeck Can Help: Hookdeck can hold the shared secret at the edge and present your app a clean, verified request, so the secret isn't spread across every consumer's logs.

The GET handshake must return 200

The Problem: If the GET validation doesn't return 200 (endpoint not public, too slow, or gated on the secret), Mailchimp refuses to save the webhook.

Why It Happens: Mailchimp validates reachability with a GET on save.

Workarounds:

  • Answer the GET with 200 unconditionally, and make sure the endpoint is public HTTPS.

How Hookdeck Can Help: Hookdeck gives you a stable, always-reachable endpoint that answers the handshake, decoupled from your app's availability.

Form-encoded, not JSON

The Problem: Payloads are application/x-www-form-urlencoded with bracket-notation keys. Handlers expecting JSON, or reading flat data[id] keys, get undefined.

Why It Happens: Mailchimp posts form data, not JSON.

Workarounds:

  • Parse with a form parser that nests bracket notation, then read type and data.

How Hookdeck Can Help: Hookdeck normalizes and forwards the payload, so downstream services get a consistent shape.

Best practices

Verify the URL secret in constant time

Compare the ?secret= query param against your stored secret with crypto.timingSafeEqual / hmac.compare_digest, never ===.

Answer the GET handshake with 200

Return 200 on the GET unconditionally so the webhook saves; don't require the secret there.

Use HTTPS and scrub the secret from logs

The secret is in the URL, so encrypt in transit and keep query strings out of logs.

Acknowledge fast, process asynchronously

Respond within Mailchimp's ~10-second window and defer work to a queue. See why to process webhooks asynchronously.

Make Mailchimp webhooks production-ready

Hookdeck adds verification, deduplication, and observability to Mailchimp's unsigned webhooks

Conclusion

Mailchimp webhooks have no signature, so authenticity rests on the GET URL-validation handshake and an unguessable ?secret= query param you compare in constant time, over form-encoded payloads. Answer the GET with 200, verify the secret, use HTTPS, and expand the bracket-notation fields.

Hookdeck adds verification, deduplication, filtering, and delivery observability to Mailchimp's unsigned webhooks, so your app processes a trustworthy, monitored stream.

Get started with Hookdeck for free and handle Mailchimp webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.