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.
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:- Your integration creates a Gift via
POST /api/v2/Gift/Transaction. - Virtuous processes the Transaction and creates the real Gift.
- Virtuous fires a
giftCreatewebhook to your endpoint. - Your handler sees a “new” Gift it doesn’t have locally and… creates it in your platform.
- The creation in your platform triggers your push queue.
- Your submitter sends
POST /api/v2/Gift/Transactionfor the same Gift again. - Loop continues.
Pattern 1: source identification on every write
Every write you make to Virtuous includes your platform name intransactionSource (for Gifts) or referenceSource (for Contacts). On the webhook receiving side, check that field before treating the event as new data:
JavaScript
Pattern 2: per-record sync state
Each record (Contact, Gift) on your platform’s side carries metadata about its sync state:JavaScript
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
JavaScript
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
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.
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
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.
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:| State | Meaning |
|---|---|
in_sync | Both sides agree on the record. No pending changes in either direction. |
partner_pending | A change was made on your platform side, queued or in-flight to Virtuous. |
virtuous_pending | A change came in from Virtuous, recently applied locally. Outbound writes suppressed for the cooldown window. |
conflict | Both sides modified the record around the same time and the conflict requires explicit resolution (timestamps inconclusive, or per-field ownership not yet defined). |
| From | To | Trigger |
|---|---|---|
in_sync | partner_pending | Partner-side write |
in_sync | virtuous_pending | Inbound webhook applied |
partner_pending | in_sync | giftCreate/contactUpdate webhook confirms the submission |
virtuous_pending | in_sync | Cooldown window elapsed |
| Any | conflict | Source-of-truth resolution returned ambiguous |
conflict | in_sync | Manual resolution (admin UI, custom rule) |
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:- Bulk export all Contacts and Gifts from Virtuous via Query endpoints — see Query Contacts by Filters.
- Bulk export all relevant records from your platform.
- Cross-match by
referenceSource/referenceId, email, or other strong signals. - For matches, set
sync_state: in_syncand capture both IDs. - For records only on Virtuous side, create matching records on your platform with
sync_state: in_sync. - For records only on your platform side, submit Transactions to Virtuous and track as
partner_pending.
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
customer_id in the partner-side record.
Disabling a customer’s sync
If a customer asks to pause their integration:- Deactivate the Virtuous webhook subscription via
PUT /api/Webhook/{webhookId}/Active?active=false. - Stop the customer’s submitter worker.
- Stop the customer’s reconciliation poller.
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.