Guide to Mailchimp Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Configuration | Audience > Settings > Webhooks (per audience/list) |
| Verification | No signature; verify an unguessable ?secret= query param (timing-safe) |
| Setup handshake | Mailchimp sends a GET on save; return 200 or it won't save |
| Payload | application/x-www-form-urlencoded, with bracket-notation nesting (data[merges][FNAME]) |
| Timeout | Respond within ~10 seconds |
| SDK | None |
Common events
Mailchimp dispatches on a top-level type field. There are six event types:
type | Fires when |
|---|---|
subscribe | A contact joins the audience |
unsubscribe | A contact leaves (action is unsub or delete; reason manual or abuse) |
profile | A contact updates their profile |
upemail | A contact changes their email address |
cleaned | An address is cleaned (reason hard bounce or abuse) |
campaign | A 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
typeanddata.
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.