> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://mailchimp.com/developer/marketing/api-concepts/webhooks/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://mailchimp.com/_mcp/server. # Webhooks > Keep your data store in sync with your Mailchimp audience via webhooks. ## At a glance Webhooks are a helpful tool that you can use to collect information about audience changes in Mailchimp as they happen. By entering a valid URL that’s set up to accept HTTP POST requests, you can receive updates on subscriptions, changed email addresses, campaign sending, and more. You can use webhooks to: * Alert your application when a campaign has finished sending * Keep your client’s profile data in sync with your own database * Detect when an email address starts bouncing For the purposes of this guide, we run a messaging app for vegetarians called Chatatouille. We use Mailchimp for our marketing emails, and we want to keep our application’s database in sync with our Mailchimp audience data. We’ll create a webhook that updates our database every time a user subscribes or unsubscribes from our mailing list. ## What you’ll need * A Mailchimp account * An audience you would like to use a webhook with * A callback URL for your application that can accept HTTP POST requests * Your [API key](/marketing/build/get-started/generate-your-api-key) ## Set up your callback URL In order to create our webhook, we need to provide a callback URL that accepts HTTP POST requests. In the sample code below, you’ll see an example of what your application code might look like, but if you just want to test out a webhook to see what the payloads look like without setting up and deploying the webhook handler in your application, you can also use a service like [RequestBin](https://requestbin.com/) to stand up a callback URL without deploying anything. When a webhook triggers based on your configured settings, Mailchimp sends an HTTP POST request to the URL you specified. If the URL is unavailable or takes more than 10 seconds to respond, the request is canceled and the system will try again later. Retries happen at increasing intervals over the course of 75 minutes. Excessive or unresponsive webhook requests may be dropped or disabled at Mailchimp’s discretion. > **Note** > > **Note**: Mailchimp strongly recommends using an HTTPS URL as your webhook callback URL. You can further increase the security of your webhook setup by using a URL containing a hard-to-guess secret and by checking this secret in your callback code. ## Create a new webhook There are two ways to set up our webhook: in the Mailchimp web app, or [through the API](/marketing/api/lists/create-webhook). In this guide, we’ll walk through setting it up using the Mailchimp app. To create a webhook: 1. Log into Mailchimp and navigate to [**Audience**](https://admin.mailchimp.com/audience/)\*\*\*\* 2. Select the audience you want to work with in the the **Current Audience** dropdown 3. Click the **Manage Audience** dropdown button and select **Settings** 4. On the Settings page, click **Webhooks** 5. Click the **Create New Webhook** button 6. In the Callback URL field, add the URL of the integration or application where you want to send webhook requests—this URL will receive data about your Mailchimp audience 7. Select the boxes next to each update type to choose the events that will trigger your webhook—in this guide, we’ll choose **Subscribes** and **Unsubscribes** 8. Click \*\*Save \*\*to save your new webhook After you click Save, Mailchimp displays a **signing secret** for your webhook. Signature verification is optional — if you don't plan to verify incoming deliveries, you can safely dismiss this dialog and skip the Verifying Webhook Signatures section below. If you do want to verify signatures, copy the secret now and store it somewhere secure (for example, in your application's environment variables or a secrets manager). **The secret is shown exactly once and cannot be retrieved later.** If you dismiss without saving it, you'll need to delete the webhook and create a new one to get a new secret. The webhook will now notify your application of subscribe and unsubscribe events as they occur. ## API Creation You can also create list webhooks programmatically via the API. The create response includes a `signing_secret` field with the one-time plaintext value — the same "shown exactly once" contract applies. See the [Add Webhook API reference](/marketing/api/lists/create-webhook) for request/response details. ## Handling the webhook response in your application Now that we have the webhook set up to alert us to changes in subscription status, we need to handle the callback data in our application. (This code should be accessible via the webhook URL you set up previously.) The body of the webhook request is sent as `application/x-www-form-urlencoded` data. The \*\*Subscribes \*\*event, ingested and parsed as JSON, will look something like this: ```json { "type": "subscribe", "fired_at": "2009-03-26 21:35:57", "data": { "id": "8a25ff1d98", "list_id": "a6b5da1054", "email": "api@mailchimp.com", "email_type": "html", "ip_opt": "10.20.10.30", "ip_signup": "10.20.10.30", "merges": { "EMAIL": "api@mailchimp.com", "FNAME": "Mailchimp", "LNAME": "API", "INTERESTS": "Group1,Group2" } } } ``` The body of the webhook request for the \*\*Unsubscribes \*\*event, ingested and parsed as JSON, will look something like this: ```json { "type": "unsubscribe", "fired_at": "2009-03-26 21:40:57", "data": { "action": "unsub", "reason": "manual", "id": "8a25ff1d98", "list_id": "a6b5da1054", "email": "api+unsub@mailchimp.com", "email_type": "html", "ip_opt": "10.20.10.30", "campaign_id": "cb398d21d2", "merges": { "EMAIL": "api+unsub@mailchimp.com", "FNAME": "Mailchimp", "LNAME": "API", "INTERESTS": "Group1,Group2" } } } ``` Now that we know what the webhook data will look like, we’ll write the code that will handle it on our server. For the purposes of this example, imagine that fakeDB is a package with functions we use to interact with Chatatouille’s database. The code could look something like this: **`Node`** ```javascript title="Node" const express = require("express"); const bodyParser = require("body-parser"); // this is a stand-in for the code you'd use to write to your own database const fakeDB = require("fakeDB"); const app = express(); app.use(bodyParser.json()) app.use(bodyParser.urlencoded({ extended: true })); app.post("/", (req, res) => { const { type, data } = req.body; if (type === "subscribe") { fakeDB.subscribeUser(data); } else if (type === "unsubscribe") { fakeDB.unsubscribeUser(data.id); } }); app.listen(port, () => console.log(`Listening at http://localhost:3000`) ); ``` **`PHP`** ```php title="PHP" **Note** > > **Note:** Always verify the signature against the **raw, unmodified request body**. Parsing the body as JSON or URL-decoding it before verification may change the bytes and cause a mismatch. > > Use a constant-time comparison function (e.g., `hash_equals`, `hmac.compare_digest`, `crypto.timingSafeEqual`). A standard equality check leaks timing information that can be exploited. **`PHP`** ```php title="PHP" $toleranceSeconds) { throw new RuntimeException('Webhook timestamp is outside the tolerance window.'); } $expectedSig = hash_hmac('sha256', $timestamp . '.' . $rawBody, $signingSecret); if (!hash_equals($expectedSig, $receivedSig)) { throw new RuntimeException('Webhook signature verification failed.'); } } $signingSecret = getenv('MAILCHIMP_WEBHOOK_SECRET'); $signatureHeader = $_SERVER['HTTP_X_MAILCHIMP_SIGNATURE'] ?? ''; $rawBody = file_get_contents('php://input'); try { verifyMailchimpWebhook($signingSecret, $signatureHeader, $rawBody); } catch (RuntimeException $e) { http_response_code(400); exit; } $event = json_decode($rawBody, true); ``` **`Node`** ```javascript title="Node" const crypto = require('crypto'); const http = require('http'); function verifyMailchimpWebhook(signingSecret, signatureHeader, rawBody, toleranceSeconds = 300) { const tsMatch = signatureHeader.match(/\bt=(\d+)\b/); const sigMatch = signatureHeader.match(/\bv1=([0-9a-f]{64})\b/); if (!tsMatch || !sigMatch) { throw new Error('Missing or malformed X-Mailchimp-Signature header.'); } const timestamp = parseInt(tsMatch[1], 10); const receivedSig = sigMatch[1]; if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) { throw new Error('Webhook timestamp is outside the tolerance window.'); } const expectedSig = crypto .createHmac('sha256', signingSecret) .update(`${timestamp}.${rawBody}`) .digest('hex'); const expected = Buffer.from(expectedSig, 'hex'); const received = Buffer.from(receivedSig, 'hex'); if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) { throw new Error('Webhook signature verification failed.'); } } http.createServer((req, res) => { if (req.method !== 'POST') { res.writeHead(405).end(); return; } const chunks = []; req.on('data', chunk => chunks.push(chunk)); req.on('end', () => { const rawBody = Buffer.concat(chunks); try { verifyMailchimpWebhook( process.env.MAILCHIMP_WEBHOOK_SECRET, req.headers['x-mailchimp-signature'] ?? '', rawBody ); } catch (err) { res.writeHead(400).end('Invalid signature.'); return; } const event = JSON.parse(rawBody); res.writeHead(200).end(); }); }).listen(3000); ``` **`Python`** ```python title="Python" import hashlib import hmac import json import os import re import time from http.server import BaseHTTPRequestHandler, HTTPServer def verify_mailchimp_webhook( signing_secret: str, signature_header: str, raw_body: bytes, tolerance_seconds: int = 300, ) -> None: ts_match = re.search(r'\bt=(\d+)\b', signature_header) sig_match = re.search(r'\bv1=([0-9a-f]{64})\b', signature_header) if not ts_match or not sig_match: raise ValueError('Missing or malformed X-Mailchimp-Signature header.') timestamp = int(ts_match.group(1)) received_sig = sig_match.group(1) if abs(int(time.time()) - timestamp) > tolerance_seconds: raise ValueError('Webhook timestamp is outside the tolerance window.') signed_string = f'{timestamp}.'.encode() + raw_body expected_sig = hmac.new(signing_secret.encode(), signed_string, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected_sig, received_sig): raise ValueError('Webhook signature verification failed.') class WebhookHandler(BaseHTTPRequestHandler): def do_POST(self): raw_body = self.rfile.read(int(self.headers['Content-Length'])) try: verify_mailchimp_webhook( os.environ['MAILCHIMP_WEBHOOK_SECRET'], self.headers.get('X-Mailchimp-Signature', ''), raw_body, ) except ValueError: self.send_response(400); self.end_headers(); return event = json.loads(raw_body) self.send_response(200); self.end_headers() HTTPServer(('', 3000), WebhookHandler).serve_forever() ``` **`Ruby`** ```ruby title="Ruby" require 'json' require 'openssl' require 'rack' require 'rack/utils' require 'webrick' def verify_mailchimp_webhook(signing_secret, signature_header, raw_body, tolerance_seconds: 300) ts_match = signature_header.match(/\bt=(\d+)\b/) sig_match = signature_header.match(/\bv1=([0-9a-f]{64})\b/) raise 'Missing or malformed X-Mailchimp-Signature header.' unless ts_match && sig_match timestamp = ts_match[1].to_i received_sig = sig_match[1] raise 'Webhook timestamp is outside the tolerance window.' \ if (Time.now.to_i - timestamp).abs > tolerance_seconds expected_sig = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, "#{timestamp}.#{raw_body}") raise 'Webhook signature verification failed.' \ unless Rack::Utils.secure_compare(expected_sig, received_sig) end app = lambda do |env| req = Rack::Request.new(env) raw_body = req.body.read begin verify_mailchimp_webhook( ENV.fetch('MAILCHIMP_WEBHOOK_SECRET'), env['HTTP_X_MAILCHIMP_SIGNATURE'] || '', raw_body ) rescue RuntimeError return [400, {}, ['Invalid signature.']] end event = JSON.parse(raw_body) [200, {}, ['']] end Rack::Handler::WEBrick.run app, Port: 3000 ``` ## More resources * [Import and export contacts](/marketing/build/quickstarts/import-and-export-contacts) * [Webhooks API Reference](/marketing/api/lists/list-webhooks) * [API structure](/marketing/api-concepts/api-structure) > Find all the Mailchimp API documentation and tools developers need to send marketing and transactional emails.