Skip to main content
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 and other ESPs. The differences are mostly on the source-platform side: event names, API shapes, and segmentation models.
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.

Architecture

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 — the source-platform-specific receivers differ, but the Virtuous-side patterns are identical.

Field mapping

Mailchimp subscriber → Virtuous Contact

Virtuous fieldSource from Mailchimp
referenceSourceThe literal string "Mailchimp"
referenceIdThe Mailchimp Subscriber Hash (or the Mailchimp ID — both are stable per subscriber)
firstName / lastNameMailchimp 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 / postalMailchimp merge field ADDRESS (if structured)
contactTypeDefault 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 conceptDescription
Audience (list)The top-level grouping. A single subscriber belongs to one Audience but can be in many.
GroupWithin an Audience, optional categorization (e.g., “Newsletter Type” with values “Weekly,” “Monthly”).
TagWithin an Audience, free-form labels applied to subscribers.
The recommended mapping into Virtuous:
MailchimpVirtuous mappingNotes
Audience membershipVirtuous Tag (e.g., “Mailchimp: Newsletter Audience”)One tag per Audience the customer maintains.
Mailchimp TagVirtuous Tag (with prefix to disambiguate)Use a consistent prefix like "MC:" to keep them visually distinct.
Mailchimp GroupVirtuous Custom FieldGroups are categorical; a custom field with the same option values is the natural fit.
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.

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 eventTriggerVirtuous action
subscribeNew email signupCreate or update Contact with the new email
unsubscribeSubscriber unsubscribedUpdate Contact: set isOptedIn: false on the email; optionally add a tag
profileSubscriber updated merge fieldsUpdate Contact with new field values
upemailSubscriber changed their emailUpdate Contact’s primary email
cleanedEmail bounced or was removed for hygiene reasonsUpdate Contact: mark email as invalid; surface for cleanup
A representative webhook handler:
JavaScript
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
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.

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
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.
Always use the GET-then-PUT pattern when updating a Contact’s opt-in status — see Update a Contact. A partial PUT could clear other fields you didn’t intend to modify.

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
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). 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
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 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 statusMeaningVirtuous mapping
subscribedSubscriber confirmed and activeisOptedIn: true on the email ContactMethod
unsubscribedSubscriber opted outisOptedIn: false on the email ContactMethod
pendingSubscriber signed up but hasn’t confirmed (double opt-in)Hold sync until confirmation — don’t create the Virtuous Contact yet
cleanedEmail removed for hygieneisOptedIn: false + ContactNote
transactionalReceives transactional emails only, not marketingisOptedIn: 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
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 recipe but applied to Contacts:
1

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.
2

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.
3

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.
4

Reconcile after the import

Confirm the count of synced Contacts matches the count of exported subscribers. Investigate any that failed to sync.
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.

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

Constant Contact to Virtuous CRM

The same architecture applied to Constant Contact — different source platform, same Virtuous-side patterns.

Build a Nightly Data Sync

For ESPs without webhook support, the batched alternative to event-driven sync.

Build a Two-Way Sync

The general two-way sync architecture this recipe instantiates.

Stripe to Virtuous CRM

A donation-side companion recipe with the same architectural patterns.
Last modified on May 21, 2026