Skip to main content
This recipe walks through building a Constant Contact-to-Virtuous integration. The Virtuous-side architecture is identical to the Mailchimp recipe — if you’ve already implemented that integration, most of the code carries over. The differences are on the Constant Contact side: Constant Contact’s API surface, event model, and segmentation primitives differ from Mailchimp’s, and (significantly) Constant Contact’s webhook capabilities are more limited.
This recipe describes Constant Contact’s data model based on general public documentation. Like all third-party platforms, Constant Contact’s API evolves — confirm against their current documentation before using specific field names. The Virtuous-side mapping is the stable part.

How Constant Contact differs from Mailchimp

Three architectural differences affect the integration pattern:
ConcernMailchimpConstant Contact
Webhook coverageComprehensive — subscribe, unsubscribe, profile change, email change, cleanedMore limited — webhook events depend on tier and feature configuration
Segmentation primitivesAudience + Group + TagList + Custom Field + (tier-dependent) Tags
Subscriber identifierSubscriber Hash (stable) or IDContact ID (typically a GUID)
The most impactful difference is webhook coverage. Where the Mailchimp recipe is fully event-driven, the Constant Contact recipe typically blends webhooks (for the events available) with periodic polling (for the events not delivered via webhook). The result is a hybrid: event-driven where possible, polled where necessary.
Confirm Constant Contact’s current webhook event catalog and tier requirements before implementation. If the customer’s Constant Contact tier doesn’t include the events your integration needs, fall back to the polling pattern described in Build a Nightly Data Sync.

Architecture

The hybrid pattern: webhooks handle real-time events where available, and a polling worker fills in the gaps by querying Constant Contact’s API for changes since the last poll.

Field mapping

Constant Contact Contact → Virtuous Contact

Virtuous fieldSource from Constant Contact
referenceSourceThe literal string "Constant Contact"
referenceIdThe Constant Contact Contact ID (typically a GUID)
firstName / lastNameConstant Contact first_name / last_name
email (with emailType: "Home Email")Constant Contact’s primary email address
phone (with phoneType: "Mobile Phone")Constant Contact phone_number if collected
address1 / city / state / postalConstant Contact’s structured address fields
contactTypeDefault "Household"

Constant Contact Lists → Virtuous Tags

The recommended mapping:
Constant ContactVirtuous mapping
List membershipVirtuous Tag, prefixed "CC:" for visual distinction
Custom field valuesVirtuous custom fields (one-to-one mapping where field names align)
Suppressed/unsubscribed statusisOptedIn: false on the email ContactMethod
As with Mailchimp, confirm the mapping with the customer’s marketing team. Some customers prefer to map Constant Contact lists to Virtuous custom field values (e.g., a “Subscriber Status” custom field with options “Newsletter,” “Donor Updates”) rather than tags.

Step 1: receive webhook events (where available)

The webhook handler for Constant Contact events that are delivered via webhook:
JavaScript
import express from 'express';

const app = express();

app.post('/constant-contact/webhook', express.json(), async (req, res) => {
  // Verify signature per Constant Contact's documentation
  if (!verifyConstantContactSignature(req)) {
    return res.status(401).send('Invalid signature');
  }

  const events = Array.isArray(req.body) ? req.body : [req.body];
  for (const event of events) {
    await db.virtuous_sync_queue.insert({
      customer_id: resolveCustomerFromAccountId(event.account_id),
      source: 'constant_contact',
      source_event_type: event.event_type,
      source_record_id: event.contact_id,
      payload: event,
      status: 'pending',
    });
  }

  res.status(200).send('OK');
});
Multi-event payloads are common with Constant Contact (the platform batches events into single webhook deliveries). Process each in the array.

Step 2: poll for changes not delivered via webhook

For events not in your customer’s Constant Contact webhook configuration, run a polling worker that queries the Constant Contact API for changes since the last poll:
JavaScript
async function pollConstantContactChanges(customerId) {
  const ccToken = await loadCustomerConstantContactToken(customerId);
  const lastPolledAt = await db.constant_contact_sync_state.getLastPolledAt(customerId);

  // Query Constant Contact for contacts modified since the last poll
  const response = await fetch(
    `https://api.cc.email/v3/contacts?updated_after=${encodeURIComponent(lastPolledAt)}&limit=500`,
    {
      headers: { Authorization: `Bearer ${ccToken}` },
    }
  );

  if (!response.ok) {
    throw new Error(`Constant Contact poll failed: ${response.status}`);
  }
  const page = await response.json();

  for (const contact of page.contacts) {
    await db.virtuous_sync_queue.insert({
      customer_id: customerId,
      source: 'constant_contact',
      source_event_type: 'poll',
      source_record_id: contact.contact_id,
      payload: contact,
      status: 'pending',
    });
  }

  // Update sync state
  await db.constant_contact_sync_state.setLastPolledAt(customerId, new Date().toISOString());
}
The polling interval depends on the customer’s tolerance for staleness:
IntervalUse case
Every 15 minutesActive customer with frequent subscriber changes
HourlyMost customers — balances freshness against Constant Contact API quota
DailyReporting-only integrations
Polling consumes Constant Contact API quota — and partner integrations sometimes share that quota across multiple customers. Set the polling interval based on the customer’s freshness requirements rather than the most aggressive interval the API allows. A 15-minute poll is rarely worth the quota cost over a 1-hour poll for most use cases.

Step 3: submit subscriber events to Virtuous

The submitter worker is largely the same as the Mailchimp version — drain the queue and submit Contact Transactions:
JavaScript
function mapToContactTransaction(ccContact) {
  return {
    referenceSource: 'Constant Contact',
    referenceId: ccContact.contact_id,
    firstName: ccContact.first_name,
    lastName: ccContact.last_name,
    emailType: 'Home Email',
    email: ccContact.email_address?.address,
    phoneType: 'Mobile Phone',
    phone: ccContact.phone_number,
    address1: ccContact.street_addresses?.[0]?.street,
    city: ccContact.street_addresses?.[0]?.city,
    state: ccContact.street_addresses?.[0]?.state,
    postal: ccContact.street_addresses?.[0]?.postal_code,
    country: ccContact.street_addresses?.[0]?.country ?? 'US',
    contactType: 'Household',
    tags: ccContact.list_memberships
      ?.map((lm) => `CC: ${listNameFromId(lm.list_id)}`)
      .join(', '),
    originSegmentCode: 'CONSTANT-CONTACT-SYNC',
  };
}
The submission pattern (queue → submitter → Virtuous Transaction → batch processing → webhook confirmation) is identical to Mailchimp’s. See Sync External Donations into Virtuous for the underlying mechanics.

Handling unsubscribes

When a subscriber opts out of all Constant Contact emails (opt_out state in CC, or the contact’s email status changes to unsubscribed), update the Virtuous Contact’s email ContactMethod with isOptedIn: false. The implementation matches the Mailchimp recipe’s unsubscribe handler.

Handling bounces

Constant Contact’s bounce categorization (hard bounce, soft bounce, abuse complaint) maps to the same Virtuous fields used for Mailchimp cleaned events: set isOptedIn: false and surface a ContactNote with the reason.

Step 4: propagate Virtuous changes back to Constant Contact

The reverse direction mirrors the Mailchimp pattern:
JavaScript
async function handleVirtuousContactUpdate(event) {
  const contact = event.data;

  const ccRef = (contact.contactReferences || []).find(
    (r) => r.source === 'Constant Contact'
  );
  if (!ccRef) return;

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

  // For each tag prefixed 'CC:' that was added, add the contact to that list
  const added = [...currentTags].filter((t) => !previousTags.has(t) && t.startsWith('CC:'));
  const removed = [...previousTags].filter((t) => !currentTags.has(t) && t.startsWith('CC:'));

  for (const tag of added) {
    const listId = listIdFromTagName(tag);
    await constantContactClient.addToList(ccRef.id, listId);
  }
  for (const tag of removed) {
    const listId = listIdFromTagName(tag);
    await constantContactClient.removeFromList(ccRef.id, listId);
  }

  await db.contact_sync_state.setTags(contact.id, [...currentTags]);
}
The sync-loop defense from Build a Two-Way Sync applies — track the last update direction per Contact to prevent echoes.

Bulk import on initial connection

When a customer first connects Constant Contact to Virtuous, the import pattern is the same as for Mailchimp: page through Constant Contact’s full contact list, treat each as a synthetic event, and let the submitter drain the queue.
JavaScript
async function bulkImportConstantContactContacts(customerId) {
  const ccToken = await loadCustomerConstantContactToken(customerId);
  let cursor = null;

  do {
    const url = cursor
      ? `https://api.cc.email/v3/contacts?cursor=${encodeURIComponent(cursor)}&limit=500`
      : `https://api.cc.email/v3/contacts?limit=500`;

    const response = await fetch(url, { headers: { Authorization: `Bearer ${ccToken}` } });
    const page = await response.json();

    for (const contact of page.contacts) {
      await db.virtuous_sync_queue.insert({
        customer_id: customerId,
        source: 'constant_contact',
        source_event_type: 'bulk_import',
        source_record_id: contact.contact_id,
        payload: contact,
        status: 'pending',
      });
    }

    cursor = page._links?.next?.href;
  } while (cursor);
}
The submitter then drains the queue at the configured rate. As with the Mailchimp bulk import, throttle below the 1,500/hour Virtuous rate limit and coordinate the import timing with the customer’s team — see Import Historical Gifts for the broader pattern (the same approach applies to Contact backfills).

Common edge cases

A subscriber exists in multiple lists

Constant Contact subscribers can belong to multiple Lists. Each List membership maps to a separate Virtuous Tag with the "CC:" prefix. A subscriber in three lists has three CC-prefixed tags on their Virtuous Contact.

A subscriber changes their email address

Constant Contact identifies subscribers by ID, not by email — an email change is a profile update, not a new subscriber. Find the existing Virtuous Contact by referenceId and update the email ContactMethod’s value.

A Contact is deleted in Constant Contact

If Constant Contact webhooks include contact.deleted events (or the polling worker detects a contact that disappeared), the recommended Virtuous-side action is not to delete the Contact — donations and other history attached to the Contact are still valuable. Instead, set isOptedIn: false on the email and add a ContactNote explaining the deletion source.

Constant Contact tier doesn’t support webhooks

If the customer’s tier doesn’t include webhooks at all, drop the webhook receiver entirely and rely on polling alone. The integration becomes effectively a nightly (or hourly) sync — covered in Build a Nightly Data Sync.

Production readiness checklist

  • Constant Contact OAuth flow implemented for per-customer authentication.
  • Webhook signature verification implemented per Constant Contact’s documentation.
  • Polling worker fills in the gaps for events not delivered via webhook.
  • The Constant Contact Contact ID is used as the Virtuous referenceId.
  • Unsubscribe / opt-out / bounce events set isOptedIn: false on the email ContactMethod.
  • Constant Contact-derived tags use the "CC:" prefix.
  • Sync-loop defense in place to prevent echoes from Virtuous changes back to Constant Contact.
  • Multi-tenant isolation: per-customer tokens, per-customer queues, per-customer polling state.
  • Polling cadence balances freshness against Constant Contact API quota.
  • Bulk-import path throttles below the Virtuous 1,500/hour rate limit.

Where to go next

Mailchimp to Virtuous CRM

The fully event-driven companion recipe — same Virtuous-side patterns, different source platform.

Build a Nightly Data Sync

The pure-polling alternative when webhook support is unavailable.

Build a Two-Way Sync

The general two-way architecture this recipe instantiates.

Reconcile Failed Syncs

Reconciliation is especially important when webhook coverage is incomplete — polling gaps are a regular source of sync drift.
Last modified on May 21, 2026