> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Mailchimp to Virtuous CRM

> A complete integration recipe for syncing email subscribers between Mailchimp and Virtuous CRM+ — Audience-to-Tag mapping, subscriber lifecycle events, and bidirectional email preference sync.

This recipe walks through building a Mailchimp-to-Virtuous integration: receiving subscriber events from Mailchimp, mapping subscribers to Virtuous Contacts, propagating list membership as Tags or custom field values, and keeping email preferences synchronized in both directions.

The Virtuous-side patterns are stable across all email-platform integrations — the same architecture applies almost identically to [Constant Contact](/crm/recipes/constant-contact-to-virtuous) and other ESPs. The differences are mostly on the source-platform side: event names, API shapes, and segmentation models.

<Note>
  This recipe describes Mailchimp's data model and event types based on the platform's general public documentation. Mailchimp evolves its API over time — confirm against Mailchimp's current docs before relying on specific field names. The Virtuous-side mapping is the durable part of the recipe.
</Note>

## Architecture

```mermaid theme={null}
graph LR
  MC["Mailchimp<br/>(subscriber events)"]
  MCWebhook["Mailchimp Webhook<br/>Receiver"]
  Queue["Outbound Queue<br/>(per-customer)"]
  Submitter["Virtuous Submitter<br/>Worker"]
  Virtuous["Virtuous CRM+"]
  VWebhook["Virtuous Webhook<br/>Receiver"]
  TagSync["Tag Sync Worker<br/>(Virtuous → Mailchimp)"]

  MC -->|subscribe, update, unsubscribe| MCWebhook
  MCWebhook --> Queue
  Queue --> Submitter
  Submitter -->|POST /api/Contact/Transaction| Virtuous
  Virtuous -->|contactUpdate webhook| VWebhook
  VWebhook --> TagSync
  TagSync -->|update list membership| MC
```

Three workers:

* **Inbound from Mailchimp** captures subscriber events (subscribe, update profile, unsubscribe, bounce) and writes them to the outbound queue.
* **Submitter** drains the queue into Virtuous as Contact Transactions.
* **Tag sync** propagates Virtuous-side tag changes back to Mailchimp list membership.

The architecture parallels [Stripe to Virtuous](/crm/recipes/stripe-to-virtuous) — the source-platform-specific receivers differ, but the Virtuous-side patterns are identical.

***

## Field mapping

### Mailchimp subscriber → Virtuous Contact

| Virtuous field                             | Source from Mailchimp                                                                        |
| ------------------------------------------ | -------------------------------------------------------------------------------------------- |
| `referenceSource`                          | The literal string `"Mailchimp"`                                                             |
| `referenceId`                              | The Mailchimp Subscriber Hash (or the Mailchimp ID — both are stable per subscriber)         |
| `firstName` / `lastName`                   | Mailchimp merge fields `FNAME` / `LNAME`                                                     |
| `email` (with `emailType: "Home Email"`)   | The subscriber's email address — Mailchimp's primary key                                     |
| `phone` (with `phoneType: "Mobile Phone"`) | Mailchimp merge field `PHONE` if configured                                                  |
| `address1` / `city` / `state` / `postal`   | Mailchimp merge field `ADDRESS` (if structured)                                              |
| `contactType`                              | Default to `"Household"` unless the customer's Mailchimp audience captures organization data |

### Mailchimp Audience and Tags → Virtuous Tags or Custom Fields

The most important architectural decision in a Mailchimp-Virtuous integration: how to represent Mailchimp's list and tag structure in Virtuous.

Mailchimp organizes subscribers into:

| Mailchimp concept   | Description                                                                                            |
| ------------------- | ------------------------------------------------------------------------------------------------------ |
| **Audience** (list) | The top-level grouping. A single subscriber belongs to one Audience but can be in many.                |
| **Group**           | Within an Audience, optional categorization (e.g., "Newsletter Type" with values "Weekly," "Monthly"). |
| **Tag**             | Within an Audience, free-form labels applied to subscribers.                                           |

The recommended mapping into Virtuous:

| Mailchimp           | Virtuous mapping                                      | Notes                                                                                  |
| ------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Audience membership | Virtuous Tag (e.g., "Mailchimp: Newsletter Audience") | One tag per Audience the customer maintains.                                           |
| Mailchimp Tag       | Virtuous Tag (with prefix to disambiguate)            | Use a consistent prefix like `"MC:"` to keep them visually distinct.                   |
| Mailchimp Group     | Virtuous Custom Field                                 | Groups are categorical; a custom field with the same option values is the natural fit. |

<Tip>
  Confirm the mapping with the customer's marketing team before implementation. Some customers prefer Mailchimp-derived data to flow into Virtuous custom fields instead of tags for cleaner segmentation. The architectural pattern is the same either way — just substitute "custom field write" for "tag update" in the code.
</Tip>

***

## Step 1: receive Mailchimp webhook events

Mailchimp delivers events to a webhook endpoint you register in their dashboard. The events relevant to Virtuous sync:

| Mailchimp event | Trigger                                          | Virtuous action                                                           |
| --------------- | ------------------------------------------------ | ------------------------------------------------------------------------- |
| `subscribe`     | New email signup                                 | Create or update Contact with the new email                               |
| `unsubscribe`   | Subscriber unsubscribed                          | Update Contact: set `isOptedIn: false` on the email; optionally add a tag |
| `profile`       | Subscriber updated merge fields                  | Update Contact with new field values                                      |
| `upemail`       | Subscriber changed their email                   | Update Contact's primary email                                            |
| `cleaned`       | Email bounced or was removed for hygiene reasons | Update Contact: mark email as invalid; surface for cleanup                |

A representative webhook handler:

```javascript JavaScript theme={null}
import express from 'express';

const app = express();

// Mailchimp sends form-urlencoded bodies, not JSON
app.post('/mailchimp/webhook', express.urlencoded({ extended: true }), async (req, res) => {
  // Mailchimp's signature scheme is different from Virtuous's — see Mailchimp docs
  if (!verifyMailchimpSignature(req)) {
    return res.status(401).send('Invalid signature');
  }

  const event = req.body;
  const eventType = event.type;
  const data = event.data;

  await db.virtuous_sync_queue.insert({
    customer_id: resolveCustomerFromAudienceId(data.list_id),
    source: 'mailchimp',
    source_event_type: eventType,
    source_record_id: data.id,                  // Mailchimp subscriber ID
    payload: data,
    status: 'pending',
  });

  res.status(200).send('OK');
});
```

Two things this gets right:

* **Form-urlencoded body, not JSON.** Mailchimp uses URL-encoded webhook bodies; standard JSON parsing middleware won't work.
* **Multi-tenant resolution at the queue boundary.** The `list_id` from Mailchimp maps to a specific customer's Audience. Resolve `customer_id` at queue insertion so downstream workers always know which Virtuous organization to target.

***

## Step 2: submit subscriber events to Virtuous

The submitter worker drains the queue, mapping Mailchimp events to Virtuous Contact Transactions:

```javascript JavaScript theme={null}
async function processMailchimpEvent(record) {
  const data = record.payload;

  switch (record.source_event_type) {
    case 'subscribe':
    case 'profile':
      return submitContactTransaction(record.customer_id, mapToContactTransaction(data));

    case 'unsubscribe':
      return updateContactOptOut(record.customer_id, data);

    case 'upemail':
      return updateContactEmail(record.customer_id, data);

    case 'cleaned':
      return markEmailInvalid(record.customer_id, data);

    default:
      console.info('Unhandled Mailchimp event', { type: record.source_event_type });
      return;
  }
}

function mapToContactTransaction(mailchimpData) {
  return {
    referenceSource: 'Mailchimp',
    referenceId: mailchimpData.id,                    // subscriber hash
    firstName: mailchimpData.merges?.FNAME,
    lastName: mailchimpData.merges?.LNAME,
    emailType: 'Home Email',
    email: mailchimpData.email,
    phoneType: 'Mobile Phone',
    phone: mailchimpData.merges?.PHONE,
    address1: mailchimpData.merges?.ADDRESS?.addr1,
    city: mailchimpData.merges?.ADDRESS?.city,
    state: mailchimpData.merges?.ADDRESS?.state,
    postal: mailchimpData.merges?.ADDRESS?.zip,
    country: mailchimpData.merges?.ADDRESS?.country ?? 'US',
    contactType: 'Household',
    tags: `Mailchimp: ${audienceNameFromId(mailchimpData.list_id)}`,
    originSegmentCode: 'MAILCHIMP-SYNC',
  };
}
```

The `subscribe` and `profile` events both use Contact Transaction — Virtuous's matching algorithm handles whether to create a new Contact or merge into an existing one. See [Create a Contact](/crm/workflows/create-a-contact).

### Handling unsubscribes

When a subscriber unsubscribes in Mailchimp, the Virtuous Contact should be updated to reflect that the email is no longer opted-in:

```javascript JavaScript theme={null}
async function updateContactOptOut(customerId, data) {
  const token = await loadCustomerApiToken(customerId);

  // Find the Contact by Mailchimp reference
  const findResponse = await fetch(
    `https://api.virtuoussoftware.com/api/Contact/Find?referenceSource=Mailchimp&referenceId=${encodeURIComponent(data.id)}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );

  if (!findResponse.ok) return; // No Virtuous Contact for this subscriber

  const contact = await findResponse.json();

  // Find the matching email on the Contact's ContactIndividuals and clear opt-in
  const updated = { ...contact };
  for (const ind of updated.contactIndividuals || []) {
    for (const method of ind.contactMethods || []) {
      if (method.type === 'Home Email' && method.value?.toLowerCase() === data.email?.toLowerCase()) {
        method.isOptedIn = false;
      }
    }
  }

  // PUT the full record back
  await fetch(`https://api.virtuoussoftware.com/api/Contact/${contact.id}`, {
    method: 'PUT',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(updated),
  });
}
```

The `isOptedIn` field on the ContactMethod is Virtuous's signal that the email is suppressed from outreach. The customer's team uses this to filter out unsubscribed addresses when sending appeals through Virtuous.

<Warning>
  Always use the `GET`-then-`PUT` pattern when updating a Contact's opt-in status — see [Update a Contact](/crm/workflows/update-a-contact#the-safe-update-pattern). A partial PUT could clear other fields you didn't intend to modify.
</Warning>

### Handling bounced or cleaned emails

When Mailchimp removes an email for hygiene reasons (hard bounce, abuse complaint, repeated soft bounces), surface this to the customer's team:

```javascript JavaScript theme={null}
async function markEmailInvalid(customerId, data) {
  // Strategy 1: Set isOptedIn: false (same as unsubscribe)
  await updateContactOptOut(customerId, data);

  // Strategy 2 (recommended): Also add a ContactNote explaining why
  await fetch('https://api.virtuoussoftware.com/api/ContactNote', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${await loadCustomerApiToken(customerId)}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      contactId: await resolveContactId(customerId, data.id),
      type: 'Email Hygiene',
      note: `Email ${data.email} was removed by Mailchimp due to ${data.reason}. The address may be invalid; consider asking the donor to confirm their preferred email.`,
      date: new Date().toISOString(),
    }),
  });
}
```

The ContactNote gives the customer's team context for the opt-out — distinguishing "donor chose to unsubscribe" from "email address is broken."

***

## Step 3: propagate Virtuous tag changes back to Mailchimp

The inbound direction: when Virtuous changes a Contact's tags or opt-in status, those changes should flow back to Mailchimp's list membership.

Subscribe to the `contactUpdate` event in Virtuous (see [Webhooks Overview](/crm/webhooks/overview)). On each event:

1. Check whether the Contact has a Mailchimp `referenceId` (i.e., your integration manages this Contact).
2. Compare the current Virtuous tags with the last known state in your sync database.
3. For any change, call the Mailchimp API to update the subscriber's audience membership or tags.

```javascript JavaScript theme={null}
async function handleVirtuousContactUpdate(event) {
  const contact = event.data;

  // Find the Mailchimp reference
  const mcRef = (contact.contactReferences || []).find(
    (r) => r.source === 'Mailchimp'
  );
  if (!mcRef) return;

  const currentTags = new Set(contact.tags?.map((t) => t.tag) || []);
  const previousTags = await db.contact_sync_state.getLastKnownTags(contact.id);

  const added = [...currentTags].filter((t) => !previousTags.has(t) && t.startsWith('MC:'));
  const removed = [...previousTags].filter((t) => !currentTags.has(t) && t.startsWith('MC:'));

  for (const tag of added) {
    await mailchimpClient.addTag(mcRef.id, tagWithoutPrefix(tag));
  }
  for (const tag of removed) {
    await mailchimpClient.removeTag(mcRef.id, tagWithoutPrefix(tag));
  }

  // Update sync state
  await db.contact_sync_state.setTags(contact.id, [...currentTags]);
}
```

Two things this gets right:

* **Filters to Mailchimp-prefixed tags only.** A Virtuous tag like `"Major Donor"` doesn't belong on a Mailchimp list — only tags that originated from or are meant for Mailchimp should round-trip back.
* **Diffs against last known state.** Without the diff, every `contactUpdate` event would re-send every tag to Mailchimp, multiplying API calls.

### Avoiding sync loops

The same sync-loop defense from [Build a Two-Way Sync](/crm/workflows/build-a-two-way-sync#the-hardest-problem-sync-loops) applies. When your Mailchimp inbound handler applies a tag to a Virtuous Contact, the resulting `contactUpdate` event will fire — your outbound handler must recognize that the change originated from Mailchimp and suppress the round-trip.

The pattern: store the timestamp of the last Mailchimp-originated update on each Contact, and in the outbound handler, ignore Virtuous events that arrived within a short window after a Mailchimp inbound update.

***

## Step 4: opt-in handling

Mailchimp distinguishes between several opt-in states:

| Mailchimp status | Meaning                                                   | Virtuous mapping                                                     |
| ---------------- | --------------------------------------------------------- | -------------------------------------------------------------------- |
| `subscribed`     | Subscriber confirmed and active                           | `isOptedIn: true` on the email ContactMethod                         |
| `unsubscribed`   | Subscriber opted out                                      | `isOptedIn: false` on the email ContactMethod                        |
| `pending`        | Subscriber signed up but hasn't confirmed (double opt-in) | Hold sync until confirmation — don't create the Virtuous Contact yet |
| `cleaned`        | Email removed for hygiene                                 | `isOptedIn: false` + ContactNote                                     |
| `transactional`  | Receives transactional emails only, not marketing         | `isOptedIn: false` if your customer uses Virtuous for marketing only |

The `pending` state is the most often-missed: a subscriber in double-opt-in flow has provided their email but hasn't yet clicked the confirmation link. Creating a Virtuous Contact at that moment means non-confirmed subscribers count toward the customer's metrics. Most customers prefer to wait for confirmation.

### Confirmation gating

```javascript JavaScript theme={null}
async function handleSubscribeEvent(record) {
  const data = record.payload;

  if (data.status === 'pending') {
    // Don't sync to Virtuous yet — wait for confirmation
    console.info('Skipping pending subscriber until confirmation', { email: data.email });
    return;
  }

  await submitContactTransaction(record.customer_id, mapToContactTransaction(data));
}
```

When the subscriber confirms (Mailchimp fires a separate `subscribe` event with `status: subscribed`), the Contact Transaction is submitted.

***

## Step 5: bulk import on initial connection

When a customer first connects their Mailchimp account, you typically need to bulk-load their existing subscribers into Virtuous. The pattern is the same as the [Import Historical Gifts](/crm/recipes/import-historical-gifts) recipe but applied to Contacts:

<Steps>
  <Step title="Export all subscribers from Mailchimp">
    Use Mailchimp's export API to pull every subscriber across all the customer's Audiences. Capture merge fields, tags, and Audience membership.
  </Step>

  <Step title="Insert each subscriber into your sync queue">
    Treat the bulk export as a series of synthetic `subscribe` events. Each subscriber becomes a queued Contact Transaction.
  </Step>

  <Step title="Throttle the submission rate">
    Bulk imports can hit the 1,500/hour Virtuous rate limit immediately if not paced. Throttle the submitter to leave headroom for live events arriving during the import.
  </Step>

  <Step title="Reconcile after the import">
    Confirm the count of synced Contacts matches the count of exported subscribers. Investigate any that failed to sync.
  </Step>
</Steps>

The reverse direction — bulk import of Virtuous Contacts into Mailchimp — is less common but follows the same pattern. Use `POST /api/Contact/Query` to page through Contacts and call Mailchimp's bulk-subscribe API.

***

## Common edge cases

### A subscriber changes their email

Mailchimp's `upemail` event signals an email change. The pattern:

1. Find the existing Virtuous Contact by Mailchimp `referenceId`.
2. Update the email ContactMethod's `value` to the new address.
3. Preserve the rest of the Contact's data.

Use the `GET`-then-`PUT` pattern — see [Update a Contact: change a primary email](/crm/workflows/update-a-contact#change-a-primary-email).

### A subscriber exists in Mailchimp but not in Virtuous

This is the normal first-time case. The Contact Transaction creates a new Contact.

### A Contact exists in Virtuous but not in Mailchimp

If your integration is the source of truth for the customer's email list, you may want to push these Contacts into Mailchimp. If Mailchimp is the source of truth, leave them alone — Mailchimp may not be the right channel for those donors.

Document the source-of-truth model with the customer before deployment.

### A subscriber is in multiple Mailchimp Audiences

Mailchimp allows the same email across multiple Audiences. The Virtuous Contact should reflect all of them — typically as multiple tags rather than multiple Contacts. The matching algorithm will merge them naturally if you submit Contact Transactions for each Audience with the same email.

***

## Production readiness checklist

* Mailchimp webhook signature verification implemented per Mailchimp's documentation.
* The Mailchimp Subscriber Hash (or ID) is used as the Virtuous `referenceId` — stable across email changes.
* `pending` subscribers are held out of sync until confirmation.
* `cleaned` and `unsubscribe` events set `isOptedIn: false` on the email ContactMethod.
* Email-only Tags (Mailchimp-derived) are prefixed for visual distinction from Virtuous-native tags.
* Sync-loop defense in place: tags originated by Mailchimp are not echoed back via the outbound direction.
* Multi-tenant credentials and Audience-to-customer mapping in place.
* Bulk-import path throttles to stay within the 1,500/hour Virtuous rate limit.
* Reconciliation pulls modified Contacts periodically to catch missed webhooks.

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Constant Contact to Virtuous CRM" icon="envelope" href="/crm/recipes/constant-contact-to-virtuous">
    The same architecture applied to Constant Contact — different source platform, same Virtuous-side patterns.
  </Card>

  <Card title="Build a Nightly Data Sync" icon="moon" href="/crm/recipes/build-a-nightly-data-sync">
    For ESPs without webhook support, the batched alternative to event-driven sync.
  </Card>

  <Card title="Build a Two-Way Sync" icon="arrow-right-arrow-left" href="/crm/workflows/build-a-two-way-sync">
    The general two-way sync architecture this recipe instantiates.
  </Card>

  <Card title="Stripe to Virtuous CRM" icon="layer-group" href="/crm/recipes/stripe-to-virtuous">
    A donation-side companion recipe with the same architectural patterns.
  </Card>
</CardGroup>
