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

# Constant Contact to Virtuous CRM

> A complete integration recipe for syncing email subscribers between Constant Contact and Virtuous CRM+ — List-to-Tag mapping, polled subscriber sync, and bidirectional email preference handling.

This recipe walks through building a Constant Contact-to-Virtuous integration. The Virtuous-side architecture is identical to the [Mailchimp recipe](/crm/recipes/mailchimp-to-virtuous) — 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.

<Note>
  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.
</Note>

## How Constant Contact differs from Mailchimp

Three architectural differences affect the integration pattern:

| Concern                     | Mailchimp                                                                     | Constant Contact                                                       |
| --------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| **Webhook coverage**        | Comprehensive — subscribe, unsubscribe, profile change, email change, cleaned | More limited — webhook events depend on tier and feature configuration |
| **Segmentation primitives** | Audience + Group + Tag                                                        | List + Custom Field + (tier-dependent) Tags                            |
| **Subscriber identifier**   | Subscriber Hash (stable) or ID                                                | Contact 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.

<Warning>
  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](/crm/recipes/build-a-nightly-data-sync).
</Warning>

***

## Architecture

```mermaid theme={null}
graph LR
  CC["Constant Contact<br/>(subscriber data)"]
  CCWebhook["Constant Contact Webhook<br/>Receiver (events available)"]
  Poller["Polling Worker<br/>(events not in webhook)"]
  Queue["Outbound Queue"]
  Submitter["Virtuous Submitter<br/>Worker"]
  Virtuous["Virtuous CRM+"]
  VWebhook["Virtuous Webhook<br/>Receiver"]
  TagSync["List Sync Worker<br/>(Virtuous → CC)"]

  CC -->|available events| CCWebhook
  CCWebhook --> Queue
  CC -.->|pulled changes| Poller
  Poller --> Queue
  Queue --> Submitter
  Submitter -->|POST /api/Contact/Transaction| Virtuous
  Virtuous -->|contactUpdate webhook| VWebhook
  VWebhook --> TagSync
  TagSync -->|update list membership| CC
```

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 field                             | Source from Constant Contact                       |
| ------------------------------------------ | -------------------------------------------------- |
| `referenceSource`                          | The literal string `"Constant Contact"`            |
| `referenceId`                              | The Constant Contact Contact ID (typically a GUID) |
| `firstName` / `lastName`                   | Constant 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` / `postal`   | Constant Contact's structured address fields       |
| `contactType`                              | Default `"Household"`                              |

### Constant Contact Lists → Virtuous Tags

The recommended mapping:

| Constant Contact               | Virtuous mapping                                                    |
| ------------------------------ | ------------------------------------------------------------------- |
| List membership                | Virtuous Tag, prefixed `"CC:"` for visual distinction               |
| Custom field values            | Virtuous custom fields (one-to-one mapping where field names align) |
| Suppressed/unsubscribed status | `isOptedIn: 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 JavaScript theme={null}
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 JavaScript theme={null}
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:

| Interval             | Use case                                                               |
| -------------------- | ---------------------------------------------------------------------- |
| **Every 15 minutes** | Active customer with frequent subscriber changes                       |
| **Hourly**           | Most customers — balances freshness against Constant Contact API quota |
| **Daily**            | Reporting-only integrations                                            |

<Tip>
  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.
</Tip>

***

## 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 JavaScript theme={null}
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](/crm/workflows/sync-external-donations#part-2-the-submitter-worker) 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](/crm/recipes/mailchimp-to-virtuous#handling-unsubscribes).

### 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](/crm/recipes/mailchimp-to-virtuous#step-3-propagate-virtuous-tag-changes-back-to-mailchimp):

```javascript JavaScript theme={null}
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](/crm/workflows/build-a-two-way-sync#the-hardest-problem-sync-loops) 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 JavaScript theme={null}
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](/crm/recipes/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](/crm/recipes/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

<CardGroup cols={2}>
  <Card title="Mailchimp to Virtuous CRM" icon="envelope" href="/crm/recipes/mailchimp-to-virtuous">
    The fully event-driven companion recipe — same Virtuous-side patterns, different source platform.
  </Card>

  <Card title="Build a Nightly Data Sync" icon="moon" href="/crm/recipes/build-a-nightly-data-sync">
    The pure-polling alternative when webhook support is unavailable.
  </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 architecture this recipe instantiates.
  </Card>

  <Card title="Reconcile Failed Syncs" icon="arrows-rotate" href="/crm/workflows/reconcile-failed-syncs">
    Reconciliation is especially important when webhook coverage is incomplete — polling gaps are a regular source of sync drift.
  </Card>
</CardGroup>
