Skip to content

Instantly share code, notes, and snippets.

@ntcho
Last active March 16, 2026 19:00
Show Gist options
  • Select an option

  • Save ntcho/44de57737ab5407240ebde465f99ad0c to your computer and use it in GitHub Desktop.

Select an option

Save ntcho/44de57737ab5407240ebde465f99ad0c to your computer and use it in GitHub Desktop.
Mailchimp Marketing Webhook Reference

Mailchimp webhooks send form-encoded POST data (not JSON) with specific structure. Understanding payload formats is critical for parsing and processing events correctly.

Payload Format Overview

Content-Type: application/x-www-form-urlencoded

Structure:

type=subscribe
&fired_at=2025-01-24+15%3A30%3A00
&data[id]=8a25ff1d98
&data[email]=user@example.com
&data[email_type]=html
&data[ip_opt]=192.168.1.100
&data[ip_signup]=192.168.1.100
&data[list_id]=a6b5da1054
&data[merges][EMAIL]=user@example.com
&data[merges][FNAME]=John
&data[merges][LNAME]=Doe
&data[merges][INTERESTS]=Group1%2CGroup2

After Parsing (example in Node.js):

{
  type: 'subscribe',
  fired_at: '2025-01-24 15:30:00',
  data: {
    id: '8a25ff1d98',
    email: 'user@example.com',
    email_type: 'html',
    ip_opt: '192.168.1.100',
    ip_signup: '192.168.1.100',
    list_id: 'a6b5da1054',
    merges: {
      EMAIL: 'user@example.com',
      FNAME: 'John',
      LNAME: 'Doe',
      INTERESTS: 'Group1,Group2'
    }
  }
}

Event: subscribe

Description: Fires when a new subscriber joins your audience via signup form, API, or admin action.

Payload Structure:

{
  type: 'subscribe',
  fired_at: '2025-01-24 15:30:00',
  data: {
    id: '8a25ff1d98',              // Unique subscriber ID (MD5 hash of lowercase email)
    email: 'user@example.com',     // Subscriber's email address
    email_type: 'html',            // Email format preference: 'html' or 'text'
    ip_opt: '192.168.1.100',       // IP address where subscriber confirmed (double opt-in)
    ip_signup: '192.168.1.100',    // IP address where subscriber initially signed up
    list_id: 'a6b5da1054',         // Audience/list ID
    merges: {
      EMAIL: 'user@example.com',
      FNAME: 'John',
      LNAME: 'Doe',
      BIRTHDAY: '05/15',
      PHONE: '+1-555-123-4567',
      ADDRESS: {
        addr1: '123 Main St',
        city: 'San Francisco',
        state: 'CA',
        zip: '94105',
        country: 'US'
      },
      INTERESTS: 'Group1,Group2'   // Interest group memberships
    }
  }
}

Key Fields:

  • data.id - Unique identifier (use for idempotency checks)
  • data.email - Subscriber's email address
  • data.list_id - Identifies which audience the event belongs to
  • data.ip_opt - IP where user confirmed subscription (important for compliance/GDPR)
  • data.merges - Object containing all merge fields (custom fields you defined)
  • data.merges.FNAME / data.merges.LNAME - Standard first/last name fields
  • data.merges.INTERESTS - Comma-separated list of interest groups subscriber joined

Use Cases:

  • Send welcome email through custom ESP
  • Add subscriber to CRM with signup source tracking
  • Track conversion from specific landing pages
  • Assign subscriber to sales rep based on custom fields
  • Trigger onboarding workflow in automation platform

Important Notes:

  • For double opt-in lists, webhook fires AFTER subscriber confirms (not at initial signup)
  • For single opt-in lists, webhook fires immediately upon signup
  • If "triggered by API" is enabled, this fires for API subscriptions too (can cause loops)

Event: unsubscribe

Description: Fires when a subscriber opts out of your audience via unsubscribe link, preferences page, or admin action.

Payload Structure:

{
  type: 'unsubscribe',
  fired_at: '2025-01-24 16:45:00',
  data: {
    id: '8a25ff1d98',
    email: 'user@example.com',
    email_type: 'html',
    ip_opt: '192.168.1.100',
    list_id: 'a6b5da1054',
    campaign_id: 'c123456789',    // Campaign ID that triggered unsubscribe (if applicable)
    reason: 'I no longer want to receive these emails',  // Optional: unsubscribe reason
    merges: {
      EMAIL: 'user@example.com',
      FNAME: 'John',
      LNAME: 'Doe',
      // ... other merge fields
    }
  }
}

Key Fields:

  • data.campaign_id - If present, indicates which campaign email caused unsubscribe
  • data.reason - Unsubscribe reason text (if subscriber provided feedback)
  • data.action - May include 'unsub' or 'delete' (delete = hard delete from list)

Use Cases:

  • Remove subscriber from other marketing channels (SMS, push notifications)
  • Add to suppression list across multiple platforms
  • Trigger exit survey or feedback form
  • Update CRM status to "unsubscribed"
  • Send to re-engagement workflow after 30 days
  • Track unsubscribe reasons for campaign optimization

Important Notes:

  • Subscriber data remains in Mailchimp even after unsubscribe (status changes to "unsubscribed")
  • If subscriber later re-subscribes, you'll receive a new subscribe webhook
  • Mailchimp compliance requires honoring unsubscribes immediately (don't continue emailing)

Event: profile

Description: Fires when a subscriber updates their profile information (name, custom fields, preferences).

Payload Structure:

{
  type: 'profile',
  fired_at: '2025-01-24 17:00:00',
  data: {
    id: '8a25ff1d98',
    email: 'user@example.com',
    email_type: 'html',
    ip_opt: '192.168.1.100',
    list_id: 'a6b5da1054',
    merges: {
      EMAIL: 'user@example.com',
      FNAME: 'Jonathan',           // Changed from 'John'
      LNAME: 'Doe',
      PHONE: '+1-555-987-6543',    // Updated phone number
      COMPANY: 'Acme Corp',        // Added company name
      // ... other merge fields
    }
  }
}

Key Fields:

  • data.merges - Contains ALL merge fields (including unchanged ones)
  • No indication of which specific field changed (compare with your database to detect changes)

Use Cases:

  • Sync profile updates to CRM in real-time
  • Update user records in your application database
  • Track data quality (e.g., how many subscribers add phone numbers)
  • Trigger workflows based on specific field changes (e.g., company added)
  • Maintain data consistency across multiple platforms

Important Notes:

  • Webhook contains ALL merge fields, not just the changed ones
  • You must compare with existing data to detect what changed
  • High frequency event if subscribers regularly update profiles
  • Consider debouncing rapid updates (multiple profile changes in short time)

Event: cleaned

Description: Fires when Mailchimp marks a subscriber's email as invalid due to hard bounces, repeated soft bounces, or spam complaints.

Payload Structure:

{
  type: 'cleaned',
  fired_at: '2025-01-24 18:15:00',
  data: {
    id: '8a25ff1d98',
    email: 'user@example.com',
    email_type: 'html',
    list_id: 'a6b5da1054',
    campaign_id: 'c123456789',    // Campaign that caused bounce (if applicable)
    reason: 'hard',               // 'hard' (invalid email) or 'abuse' (spam complaint)
    merges: {
      EMAIL: 'user@example.com',
      FNAME: 'John',
      LNAME: 'Doe',
      // ... other merge fields
    }
  }
}

Key Fields:

  • data.reason - Why email was cleaned: 'hard' (hard bounce), 'abuse' (spam complaint), 'other'
  • data.campaign_id - Campaign that triggered the cleaning event (if applicable)

Use Cases:

  • Remove invalid emails from other systems to maintain list hygiene
  • Add to global suppression list across all email providers
  • Alert admin team about potential data quality issues
  • Track bounce rates and email validation accuracy
  • Update CRM with "invalid email" status
  • Trigger email validation re-check workflow

Important Notes:

  • Cleaned subscribers cannot be re-subscribed via API (must manually archive first)
  • Mailchimp automatically prevents sending to cleaned addresses
  • Spam complaints (abuse) should trigger immediate suppression everywhere
  • High cleaning rates indicate list quality problems or permission issues

Event: upemail

Description: Fires when a subscriber changes their email address through Mailchimp's profile update page.

Payload Structure:

{
  type: 'upemail',
  fired_at: '2025-01-24 19:30:00',
  data: {
    list_id: 'a6b5da1054',
    new_id: '9b36fa2e09',          // New subscriber ID (MD5 of new lowercase email)
    new_email: 'newemail@example.com',
    old_email: 'oldemail@example.com'
  }
}

Key Fields:

  • data.old_email - Previous email address
  • data.new_email - Updated email address
  • data.new_id - New subscriber ID (recalculated based on new email)
  • Note: No data.id field in this event (use new_id)

Use Cases:

  • Update email address across all connected systems
  • Maintain user account integrity (same user, different email)
  • Track email change frequency for security monitoring
  • Update authentication systems if email is used for login
  • Migrate historical data to new email identifier

Important Notes:

  • This is a RARE event (most users don't change emails via Mailchimp)
  • The old subscriber record is deleted and new one created (different ID)
  • Update both email and subscriber ID in your systems
  • Historical campaign stats remain associated with old email

Event: campaign

Description: Fires when a campaign (email) is sent to your audience. Provides campaign metadata but not individual recipient data.

Payload Structure:

{
  type: 'campaign',
  fired_at: '2025-01-24 20:00:00',
  data: {
    id: 'c123456789',              // Campaign ID
    subject: 'January Newsletter', // Email subject line
    status: 'sent',                // Campaign status: 'sent', 'sending', 'paused', 'canceled'
    list_id: 'a6b5da1054',
    send_time: '2025-01-24 20:00:00'
  }
}

Key Fields:

  • data.id - Campaign ID (use to fetch detailed stats via Reports API)
  • data.subject - Email subject line
  • data.status - Campaign status (usually 'sent' or 'sending')

Use Cases:

  • Log campaign send times in analytics dashboard
  • Trigger post-send workflows (e.g., check engagement after 1 hour)
  • Notify team in Slack when campaign goes out
  • Track campaign frequency and timing patterns
  • Correlate campaign sends with website traffic spikes

Important Notes:

  • This webhook does NOT contain individual recipient data (no emails, no open/click tracking)
  • For email engagement tracking (opens/clicks), use Mailchimp Transactional (Mandrill) webhooks
  • For detailed campaign reports, use Mailchimp Reports API after campaign completes
  • Campaign webhooks fire when sending starts (not when complete for large lists)
/**
* Generic Mailchimp identifier.
* Usually a 10-digit alphanumeric string (e.g., 'a6b5da1054').
*/
type MailchimpId = string;
/**
* Unique identifier for a subscriber.
* This is typically the MD5 hash of the lowercase version of the subscriber's email address.
*/
type SubscriberId = string;
/**
* Unique identifier for a list/audience.
*/
type ListId = MailchimpId;
/**
* Unique identifier for a marketing campaign.
*/
type CampaignId = MailchimpId;
/**
* Format preference for the subscriber's emails.
*/
type EmailType = 'html' | 'text';
/**
* Standard physical address structure used in merge fields.
*/
type MailchimpAddress = {
addr1: string;
city: string;
state: string;
zip: string;
country: string;
};
/**
* Collection of custom fields defined in the Mailchimp audience.
*/
type MergeFields = {
/** Subscriber's email address */
EMAIL?: string;
/** Subscriber's first name */
FNAME?: string;
/** Subscriber's last name */
LNAME?: string;
/** Subscriber's birthday (e.g., '05/15') */
BIRTHDAY?: string;
/** Subscriber's phone number */
PHONE?: string;
/** Subscriber's physical address */
ADDRESS?: MailchimpAddress;
/** Comma-separated list of interest groups the subscriber joined */
INTERESTS?: string;
/** Any other custom defined fields */
[key: string]: any;
};
/**
* Common fields shared across most webhook event payloads.
*/
type BasePayload = {
/** The date and time the event occurred (Format: YYYY-MM-DD HH:MM:SS) */
fired_at: string;
};
/**
* ## Event: `subscribe`
*
* Fires when a new subscriber joins your audience via signup form, API, or admin action.
*
* ### Notes
* - For double opt-in lists, webhook fires AFTER subscriber confirms (not at initial signup).
* - For single opt-in lists, webhook fires immediately upon signup.
*
* ### Warning
* If "triggered by API" is enabled, this fires for API subscriptions too, which can
* cause infinite loops if your handler also calls the Mailchimp API.
*
* ### Use Cases
* - Send welcome email through custom ESP.
* - Add subscriber to CRM with signup source tracking.
* - Track conversion from specific landing pages.
*/
type SubscribeEventPayload = BasePayload & {
type: 'subscribe';
data: {
/** Unique subscriber ID (MD5 hash of lowercase email). Use for idempotency checks. */
id: SubscriberId;
/** Subscriber's email address */
email: string;
/** Email format preference: 'html' or 'text' */
email_type: EmailType;
/** IP address where subscriber confirmed (double opt-in). Important for GDPR/compliance. */
ip_opt: string;
/** IP address where subscriber initially signed up */
ip_signup: string;
/** Identifies which audience the event belongs to */
list_id: ListId;
/** Object containing all merge fields (custom fields) */
merges: MergeFields;
};
};
/**
* ## Event: `unsubscribe`
*
* Fires when a subscriber opts out via unsubscribe link, preferences page, or admin action.
*
* ### Notes
* - Subscriber data remains in Mailchimp even after unsubscribe (status changes to "unsubscribed").
* - If subscriber later re-subscribes, you'll receive a new `subscribe` webhook.
* - Mailchimp compliance requires honoring unsubscribes immediately.
*
* ### Use Cases
* - Remove subscriber from other marketing channels (SMS, push).
* - Update CRM status to "unsubscribed".
* - Trigger exit survey or feedback form.
*/
type UnsubscribeEventPayload = BasePayload & {
type: 'unsubscribe';
data: {
/** Unique subscriber ID */
id: SubscriberId;
/** Subscriber's email address */
email: string;
/** Email format preference */
email_type: EmailType;
/** IP address where subscriber confirmed */
ip_opt: string;
/** Identifies which audience the event belongs to */
list_id: ListId;
/** If present, indicates which campaign email caused the unsubscribe. */
campaign_id?: CampaignId;
/** Optional unsubscribe reason text provided by the subscriber. */
reason?: string;
/** * Specific action taken.
* 'unsub' = standard opt-out.
* 'delete' = subscriber was hard deleted from the list.
*/
action?: 'unsub' | 'delete';
/** Merge fields at the time of unsubscription */
merges: MergeFields;
};
};
/**
* ## Event: `profile`
*
* Fires when a subscriber updates their profile information (name, custom fields, preferences).
*
* ### Notes
* - Webhook contains ALL merge fields, not just the changed ones.
* - You must compare with your existing database records to detect which specific field changed.
* - High frequency event; consider debouncing rapid updates.
*
* ### Use Cases
* - Sync profile updates to CRM in real-time.
* - Update user records in your application database.
*/
type ProfileEventPayload = BasePayload & {
type: 'profile';
data: {
/** Unique subscriber ID */
id: SubscriberId;
/** Subscriber's email address */
email: string;
/** Email format preference */
email_type: EmailType;
/** IP address where subscriber confirmed */
ip_opt: string;
/** Identifies which audience the event belongs to */
list_id: ListId;
/** Contains ALL merge fields. Compare with your database to detect changes. */
merges: MergeFields;
};
};
/**
* ## Event: `cleaned`
*
* Fires when Mailchimp marks a subscriber's email as invalid due to hard bounces,
* repeated soft bounces, or spam complaints.
*
* ### Notes
* - Cleaned subscribers cannot be re-subscribed via API (must manually archive first).
* - Mailchimp automatically prevents sending to cleaned addresses.
* - Spam complaints (abuse) should trigger immediate suppression in all your other systems.
*
* ### Use Cases
* - Remove invalid emails from other systems to maintain list hygiene.
* - Update CRM with "invalid email" status.
*/
type CleanedEventPayload = BasePayload & {
type: 'cleaned';
data: {
/** Unique subscriber ID */
id: SubscriberId;
/** Subscriber's email address */
email: string;
/** Email format preference */
email_type: EmailType;
/** Identifies which audience the event belongs to */
list_id: ListId;
/** Campaign that caused the bounce or complaint. */
campaign_id?: CampaignId;
/** * Reason why email was cleaned:
* - 'hard': Hard bounce (invalid email).
* - 'abuse': Spam complaint.
* - 'other': Miscellaneous cleaning reason.
*/
reason: 'hard' | 'abuse' | 'other';
/** Merge fields at the time the record was cleaned */
merges: MergeFields;
};
};
/**
* ## Event: `upemail`
*
* Fires when a subscriber changes their email address through the profile update page.
*
* ### Notes
* - This is a rare event; most users do not change emails via Mailchimp profile pages.
* - The old subscriber record is deleted and a new one created (resulting in a different ID).
* - Historical campaign stats remain associated with the old email identifier.
*
* ### Use Cases
* - Update email address across all connected systems.
* - Maintain user account integrity in authentication systems.
*/
type UpdateEmailEventPayload = BasePayload & {
type: 'upemail';
data: {
/** Identifies which audience the event belongs to */
list_id: ListId;
/** Previous email address */
old_email: string;
/** Updated email address */
new_email: string;
/** New subscriber ID (recalculated MD5 hash of the new lowercase email). */
new_id: SubscriberId;
};
};
/**
* ## Event: `campaign`
*
* Fires when a marketing campaign (email) is sent to your audience.
*
* ### Notes
* - This does NOT contain individual recipient data (no emails, no open/click tracking).
* - Webhooks fire when sending starts, which may be significantly earlier than completion for large lists.
*
* ### Use Cases
* - Log campaign send times in analytics dashboards.
* - Notify team (e.g., Slack) when a campaign goes out.
*/
type CampaignEventPayload = BasePayload & {
type: 'campaign';
data: {
/** Campaign ID. Use to fetch detailed stats via Reports API. */
id: CampaignId;
/** Email subject line */
subject: string;
/** Current status of the campaign. */
status: 'sent' | 'sending' | 'paused' | 'canceled';
/** Identifies which audience the campaign was sent to */
list_id: ListId;
/** The scheduled or actual time the campaign was sent */
send_time: string;
};
};
/**
* Top-level Mailchimp Marketing Webhook Payload.
*
* @example
* Parsing form-encoded data in Node.js (Express):
* ```typescript
* import express from 'express';
*
* const app = express();
*
* // Mailchimp sends application/x-www-form-urlencoded
* // Use extended: true to support the nested square bracket notation
* // e.g., data[merges][FNAME]
* app.use(express.urlencoded({ extended: true }));
*
* app.post('/webhook', (req, res) => {
* const payload = req.body as MailchimpWebhookPayload;
* console.log(`Received ${payload.type} event at ${payload.fired_at}`);
* res.status(200).send();
* });
* ```
*/
export type MailchimpWebhookPayload =
| SubscribeEventPayload
| UnsubscribeEventPayload
| ProfileEventPayload
| CleanedEventPayload
| UpdateEmailEventPayload
| CampaignEventPayload;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment