Skip to main content
A one-way sync (your platform → Virtuous) is enough for many partner integrations — a payment processor or fundraising platform that pushes gifts in and never reads them back, for example. A two-way sync is needed when changes to a donor or gift in Virtuous should propagate back to your platform — when the customer maintains data in both systems and expects them to stay consistent. This workflow extends the one-way architecture in Sync External Donations into Virtuous with a second direction: changes initiated in Virtuous flowing back to your platform. It covers the architecture, the source-of-truth resolution decisions, the most important risk (sync loops), and the patterns for managing per-record state across both sides.

The architecture

The new parts on top of the one-way architecture:
  • Webhook receiver processes incoming Virtuous events (covered in Webhooks Overview).
  • Inbound processor applies those events to your platform’s records — creating, updating, or marking as merged.
  • Sync state tracking on each record tracks which side last modified it and when, used to resolve conflicts and prevent sync loops.
Each direction is independent. The push direction does not wait on the pull direction or vice versa. The two share state — the per-record sync metadata — but operate on independent schedules.

The hardest problem: sync loops

A two-way sync can produce loops — an infinite cascade where a write on one side triggers a webhook that triggers a write on the other side that triggers a webhook back, and so on. Without defense, the loop can run until one side rate-limits the other. Example scenario:
  1. Your integration creates a Gift via POST /api/v2/Gift/Transaction.
  2. Virtuous processes the Transaction and creates the real Gift.
  3. Virtuous fires a giftCreate webhook to your endpoint.
  4. Your handler sees a “new” Gift it doesn’t have locally and… creates it in your platform.
  5. The creation in your platform triggers your push queue.
  6. Your submitter sends POST /api/v2/Gift/Transaction for the same Gift again.
  7. Loop continues.
The defense is knowing when a change came from your own write versus from somewhere else. Two patterns work together.

Pattern 1: source identification on every write

Every write you make to Virtuous includes your platform name in transactionSource (for Gifts) or referenceSource (for Contacts). On the webhook receiving side, check that field before treating the event as new data:
JavaScript
async function handleGiftCreated(event) {
  const gift = event.data;

  // If this gift came from one of our own submissions, we already know
  // about it. Update our existing record to capture the Virtuous Gift ID.
  if (gift.transactionSource === 'YourPlatform') {
    await db.partnerGifts.update(
      { transactionId: gift.transactionId },
      { virtuousGiftId: gift.id, status: 'confirmed' }
    );
    return;
  }

  // Otherwise, this gift was created by someone else — manual entry,
  // another integration, or the Virtuous UI. Capture it on our side.
  await ingestVirtuousOriginatedGift(gift);
}
This pattern alone breaks the simplest loop: events generated by your own writes never produce a new local write.

Pattern 2: per-record sync state

Each record (Contact, Gift) on your platform’s side carries metadata about its sync state:
CREATE TABLE partner_contacts (
  id BIGSERIAL PRIMARY KEY,
  customer_id TEXT NOT NULL,
  -- ... your platform's normal fields ...

  -- Sync metadata
  virtuous_contact_id INTEGER,
  virtuous_modified_at TIMESTAMPTZ,        -- the modifiedDateTimeUtc last seen from Virtuous
  partner_modified_at TIMESTAMPTZ,          -- the timestamp of the last change on your side
  sync_state TEXT NOT NULL DEFAULT 'in_sync', -- in_sync | partner_pending | virtuous_pending
  last_sync_at TIMESTAMPTZ
);
Before submitting a partner-side change to Virtuous, check the sync state. If the record was just updated by an inbound Virtuous event, suppress the outbound write — you’d be sending Virtuous’s own data back to it:
JavaScript
async function maybeSubmitPartnerChange(partnerRecord) {
  // If the last modification was an inbound sync from Virtuous within the
  // last few minutes, this "change" is just a reflection of what Virtuous
  // told us. Suppress the outbound submission.
  if (partnerRecord.sync_state === 'virtuous_pending') {
    return;
  }

  const timeSinceLastInbound = Date.now() - new Date(partnerRecord.last_sync_at).getTime();
  if (timeSinceLastInbound < 60_000) {
    // Within 60 seconds of an inbound sync — likely echo of that sync
    return;
  }

  await queueOutboundSubmission(partnerRecord);
}
The combination of source identification and per-record sync state defends against both immediate echoes and delayed loops.

Direction A: your platform → Virtuous

This direction is covered in detail in Sync External Donations into Virtuous for Gifts and Create a Contact for Contacts. The architecture in this page extends those by adding sync-state updates. When your submitter successfully submits a Transaction:
JavaScript
await db.partner_contacts.update(
  { id: partnerContact.id },
  {
    status: 'submitted',
    submitted_at: new Date(),
    sync_state: 'partner_pending',         // waiting for Virtuous to confirm
  }
);
When the webhook arrives confirming the resulting Contact/Gift exists in Virtuous:
JavaScript
await db.partner_contacts.update(
  { id: partnerContact.id },
  {
    virtuous_contact_id: event.data.id,
    virtuous_modified_at: event.data.modifiedDateTimeUtc,
    sync_state: 'in_sync',
    last_sync_at: new Date(),
  }
);
The state transitions: in_sync → partner_pending → in_sync once Virtuous confirms.

Direction B: Virtuous → your platform

The inbound direction has two signals: webhook events (primary, real-time) and Query-based reconciliation (secondary, periodic backstop).

Inbound webhook handler

Process webhooks for the resource types you care about. After verifying the signature (see Signature Verification), route by event type:
JavaScript
async function processVirtuousEvent(event) {
  switch (event.eventType) {
    case 'contact.created':
    case 'contact.updated':
      await applyContactToPartner(event.data);
      break;
    case 'gift.created':
    case 'gift.updated':
      await applyGiftToPartner(event.data);
      break;
    case 'gift.deleted':
      await applyGiftDeletionToPartner(event.data);
      break;
    // ... other event types your integration handles
  }
}

async function applyContactToPartner(contact) {
  // 1. If this Contact has a contactReference for our platform, look up
  //    the existing partner record.
  const ourRef = (contact.contactReferences || []).find(
    (r) => r.source === 'YourPlatform'
  );

  let partnerRecord;
  if (ourRef) {
    partnerRecord = await db.partner_contacts.findOne({ id: ourRef.id });
  } else if (contact.id) {
    partnerRecord = await db.partner_contacts.findOne({ virtuous_contact_id: contact.id });
  }

  // 2. If this Contact was merged into another, remap.
  if (contact.mergedIntoContactId) {
    await db.partner_contacts.update(
      { virtuous_contact_id: contact.id },
      { virtuous_contact_id: contact.mergedIntoContactId, mergedFrom: contact.id }
    );
    return;
  }

  // 3. Apply the inbound change, setting state to suppress an outbound echo.
  if (partnerRecord) {
    await db.partner_contacts.update(
      { id: partnerRecord.id },
      {
        name: contact.name,
        email: extractPrimaryEmail(contact),
        virtuous_modified_at: contact.modifiedDateTimeUtc,
        sync_state: 'virtuous_pending',
        last_sync_at: new Date(),
      }
    );
  } else {
    // No local record — create one from the Virtuous data.
    await db.partner_contacts.insert({
      virtuous_contact_id: contact.id,
      name: contact.name,
      email: extractPrimaryEmail(contact),
      virtuous_modified_at: contact.modifiedDateTimeUtc,
      sync_state: 'in_sync',
      last_sync_at: new Date(),
    });
  }

  // 4. After a short cooldown, transition back to in_sync.
  // (This is typically done by a background job, not synchronously.)
}
The virtuous_pending state is the cooldown window. While in this state, your push direction suppresses outbound submissions for this record — even if your platform’s own code modifies the record during the cooldown (perhaps the inbound apply itself triggers an updated event on your side), the suppression prevents the loop.

Periodic Query-based reconciliation

Webhook delivery is the primary signal but not the only one. Run a periodic Query against Virtuous to catch:
  • Events that failed all webhook retries during an outage.
  • Records modified by other writers during a brief subscription deactivation.
  • Edge cases the matching algorithm produced but the webhook didn’t fire on.
The pattern is the same as in Query Contacts by Filters — query for Contacts (and separately, Gifts) modified after the last reconciliation run, and apply them through the same applyContactToPartner / applyGiftToPartner path. Run reconciliation on a slower cadence than webhooks process (every 4–12 hours is typical) so that legitimate webhook events have time to land first.

Source-of-truth resolution

A two-way sync requires answering “if the same record was modified on both sides between syncs, which side wins?” Three patterns work for different domains:

Pattern 1: most recent wins

The simplest pattern — whichever side has the most recent modification timestamp wins. Implement with timestamp comparison:
JavaScript
async function applyVirtuousChange(virtuousRecord, partnerRecord) {
  const virtuousTime = new Date(virtuousRecord.modifiedDateTimeUtc);
  const partnerTime = new Date(partnerRecord.partner_modified_at);

  if (virtuousTime > partnerTime) {
    // Virtuous wins — apply
    await applyInbound(virtuousRecord, partnerRecord);
  } else {
    // Partner side wins — submit the partner change instead
    await queueOutboundSubmission(partnerRecord);
  }
}
This pattern works for most fields. The catch: it requires that both sides record modification timestamps consistently, and that you trust both timestamps to be accurate. Clock skew between systems can produce surprising results.

Pattern 2: per-field ownership

Some fields are always owned by one side regardless of timing. For example:
  • Donor’s preferred name is owned by your platform if your platform has the donor portal; owned by Virtuous if the customer’s staff manages it there.
  • Custom field values are typically owned by whichever system the customer uses to edit them.
  • Tags are typically owned by Virtuous (set by the customer’s staff for segmentation).
  • External reference IDs are owned by the source platform.
Document per-field ownership during integration design and enforce it in your sync code — outbound writes only modify fields your platform owns; inbound writes only modify fields Virtuous owns. Per-field ownership eliminates the conflict-resolution question for those fields.

Pattern 3: Virtuous-as-canonical for shared fields

For fields modified by both sides, declaring Virtuous as canonical is the safest default — the nonprofit’s staff who use Virtuous daily are typically the authoritative editors. Your platform mirrors Virtuous’s state for those fields and reflects changes back only when explicitly requested. This pattern reduces the engineering burden on your sync code (fewer conflict-resolution paths) at the cost of some functionality on your platform (some fields are effectively read-only on your side).

State machine

The per-record sync state has more transitions in a two-way architecture than in a one-way one. The states:
StateMeaning
in_syncBoth sides agree on the record. No pending changes in either direction.
partner_pendingA change was made on your platform side, queued or in-flight to Virtuous.
virtuous_pendingA change came in from Virtuous, recently applied locally. Outbound writes suppressed for the cooldown window.
conflictBoth sides modified the record around the same time and the conflict requires explicit resolution (timestamps inconclusive, or per-field ownership not yet defined).
Transitions:
FromToTrigger
in_syncpartner_pendingPartner-side write
in_syncvirtuous_pendingInbound webhook applied
partner_pendingin_syncgiftCreate/contactUpdate webhook confirms the submission
virtuous_pendingin_syncCooldown window elapsed
AnyconflictSource-of-truth resolution returned ambiguous
conflictin_syncManual resolution (admin UI, custom rule)
Persist this state on every record. The state machine is the runtime expression of which side currently “owns” the record’s truth.

Operational considerations

Initial reconciliation at integration start

When the integration first connects to a customer’s Virtuous organization, neither side has knowledge of the other’s records. The first sync is large and bidirectional:
  1. Bulk export all Contacts and Gifts from Virtuous via Query endpoints — see Query Contacts by Filters.
  2. Bulk export all relevant records from your platform.
  3. Cross-match by referenceSource/referenceId, email, or other strong signals.
  4. For matches, set sync_state: in_sync and capture both IDs.
  5. For records only on Virtuous side, create matching records on your platform with sync_state: in_sync.
  6. For records only on your platform side, submit Transactions to Virtuous and track as partner_pending.
The initial reconciliation can take hours or days for large customers. Run it during off-peak hours and inform the customer of the expected duration. After completion, the steady-state webhook + reconciliation pattern takes over.

Multi-tenant isolation

As with one-way sync, run separate workers per customer to isolate rate-limit budgets and prevent one customer’s load from affecting others. Each customer has their own:
  • API token
  • Webhook subscription pointing at your endpoint
  • Outbound queue and submitter worker
  • Reconciliation poller
The sync-state metadata is scoped by customer_id in the partner-side record.

Disabling a customer’s sync

If a customer asks to pause their integration:
  1. Deactivate the Virtuous webhook subscription via PUT /api/Webhook/{webhookId}/Active?active=false.
  2. Stop the customer’s submitter worker.
  3. Stop the customer’s reconciliation poller.
When reactivating, run a fresh reconciliation cycle to catch up on any changes that happened during the pause. Note from Webhooks Overview — events during the inactive subscription window are dropped, not queued.

Where to go next

Reconcile Failed Syncs

The reconciliation pattern is the safety net underneath both directions of the two-way architecture.

Handle Duplicate Records

The merged-Contact remapping path that two-way sync architectures must handle.

Sync External Donations into Virtuous

The one-way push architecture that this page extends.

Idempotency and Safe Reprocessing

The webhook-side patterns that keep the inbound direction correct under duplicate deliveries.
Last modified on May 21, 2026