Skip to main content
Duplicate records — two Contacts representing the same donor, or two Gifts recording the same donation — are the most common data-quality problem in partner integrations. The Transaction endpoints’ matching algorithm prevents many duplicates, but not all. This workflow covers the patterns for detecting duplicates, the resolution paths available to partners (and the important resolution path that is not available), and the preventive measures that reduce duplicate creation in the first place.

Why duplicates happen

Even with the recommended Transaction-pattern integration, four scenarios produce duplicate records:
CauseAffects
Insufficient matching signalTwo Contact Transactions arrive for the same donor but with no overlapping identifiers (different platforms, no shared email, no referenceSource/referenceId). The matching algorithm can’t connect them.
Direct create without lookupPOST /api/Contact does not check for duplicates — it creates a new record unconditionally. Any integration using direct create without its own lookup logic produces duplicates.
Unstable transactionIdA Gift Transaction retried with a freshly-generated transactionId instead of the original is seen as a new gift by Virtuous.
Concurrent writes from multiple sourcesThe customer’s data is written by your integration, by another partner, and by manual entry in the Virtuous UI. Two systems creating the same donor at the same moment can both succeed before either sees the other.
Some of these are preventable on your side (use Transaction endpoints, use stable transactionId, include strong matching signals). Some are not (manual entry, third-party integration behavior). Your integration needs both prevention and detection.

Contact duplicates vs. Gift duplicates

The two duplicate classes are handled differently because the resolution surface in Virtuous is different.
Duplicate typeResolution pathAPI support
Two Contacts for the same donorMerge in the Virtuous UI by an administrator.The CRM+ API has no merge endpoint. Detection is possible via mergedIntoContactId; merging is not.
Two Gifts for the same donationCreate a reversing transaction for the unwanted duplicate.POST /api/Gift/ReversingTransaction exists and is API-accessible.
The next two sections cover each in turn.

Detecting Contact duplicates

Pattern A: pre-create lookup

The primary defense — catch duplicates before creating them. Before calling POST /api/Contact/Transaction for a donor, run GET /api/Contact/Find with your referenceSource/referenceId and with email. If a Contact already exists matching either signal, use the existing record rather than submitting a Transaction. This is the pattern documented in Create a Contact, Step 2. It prevents the most common partner-introduced duplicates.

Pattern B: post-create reconciliation query

For duplicates that slip past pre-create lookup (and for duplicates created by other sources), run periodic reconciliation queries that surface likely duplicate sets:
JavaScript
async function findLikelyDuplicateContacts(token) {
  // Pull all contacts modified recently
  const response = await fetch(
    'https://api.virtuoussoftware.com/api/Contact/Query',
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        groups: [
          {
            conditions: [
              {
                parameter: 'Last Modified Date',
                operator: 'Is After',
                value: '2024-12-01T00:00:00Z',
              },
            ],
          },
        ],
        sortBy: 'name',
        skip: 0,
        take: 1000,
      }),
    }
  );

  const page = await response.json();

  // Group by email, then surface groups with more than one Contact
  const byEmail = new Map();
  for (const contact of page.list) {
    if (!contact.email) continue;
    const key = contact.email.toLowerCase().trim();
    if (!byEmail.has(key)) byEmail.set(key, []);
    byEmail.get(key).push(contact);
  }

  const duplicates = [];
  for (const [email, contacts] of byEmail.entries()) {
    if (contacts.length > 1) {
      duplicates.push({ email, contacts });
    }
  }

  return duplicates;
}
This is a starter pattern — production reconciliation uses more sophisticated matching (name + postal code, normalized phone numbers, address fingerprints) and works against larger result sets. The output is a list of likely-duplicate groups that should be surfaced to the customer for review and manual merge.

Pattern C: detecting merges after the fact

When the customer merges two Contacts in the Virtuous UI, the merged-away Contact’s record carries a mergedIntoContactId field pointing to the surviving Contact. Your sync should watch for this field on Contacts you previously tracked:
JavaScript
async function reconcileMergedContacts(token) {
  // Look at every Virtuous Contact ID you have stored on your side
  const trackedIds = await db.partnerContacts.findAll().map((c) => c.virtuousContactId);

  for (const id of trackedIds) {
    const response = await fetch(
      `https://api.virtuoussoftware.com/api/Contact/${id}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );

    if (!response.ok) continue;
    const contact = await response.json();

    if (contact.mergedIntoContactId) {
      // This Contact was merged into another. Update your local record
      // to point to the surviving Contact.
      console.log(`Contact ${id} was merged into ${contact.mergedIntoContactId}`);
      await db.partnerContacts.update(
        { virtuousContactId: id },
        { virtuousContactId: contact.mergedIntoContactId, mergedFrom: id }
      );
    }
  }
}
contactUpdate webhooks also fire when a merge occurs — subscribe to that event and check for the mergedIntoContactId field in the handler:
JavaScript
async function handleContactUpdated(event) {
  const contact = event.data;
  if (contact.mergedIntoContactId) {
    // The Contact was merged into another. Update our records.
    await db.partnerContacts.update(
      { virtuousContactId: contact.id },
      {
        virtuousContactId: contact.mergedIntoContactId,
        mergedFrom: contact.id,
        mergedAt: new Date(),
      }
    );
  }
}
Any references to the merged-away Contact ID stored elsewhere in your integration (in donation records, subscription lists, custom logs) need to be remapped to the surviving Contact ID. Failing to remap leaves orphan references that look valid until something tries to dereference them and hits a Contact that has been merged.

Resolving Contact duplicates

This is where the partner integration’s options are constrained.
The CRM+ API has no Contact merge endpoint. Partners cannot programmatically merge two duplicate Contacts. Resolution requires an administrator to merge the records inside the Virtuous UI.Your integration’s role in Contact duplicate resolution is therefore limited to: detecting duplicates, surfacing them to the customer, and handling the post-merge state via mergedIntoContactId once the customer completes the merge.
The partner-side workflow:
1

Detect the likely duplicate set

Use Pattern B above (post-create reconciliation query) to surface likely duplicate groups.
2

Surface the duplicates to the customer

Present the duplicate set in your integration’s UI — typically with the matching field (email, name) and links to each Contact’s Virtuous profile via contactViewUrl. Include a clear instruction that the merge must happen in Virtuous.
3

Wait for the customer to merge

The customer’s Virtuous administrator merges the duplicates in the Virtuous UI. This sets mergedIntoContactId on the merged-away record and (typically) fires a contactUpdate webhook.
4

Remap on your side via webhook or polling

Use the pattern from “Detecting merges after the fact” above to remap your stored references from the merged-away ID to the surviving ID.
For high-volume partners with frequent duplicates, build a queue of “pending merge” sets in your integration’s UI that the customer’s team can work through periodically. Each entry stays in the queue until your post-merge polling confirms the merge was completed.

Detecting Gift duplicates

Gift duplicates are easier to handle programmatically because the resolution path is API-accessible. The primary detection signal: two Gift records in Virtuous with the same transactionSource + transactionId pair, or two Gift records on the same Contact for the same amount and date.
JavaScript
async function findDuplicateGiftsForDonation(token, transactionSource, transactionId) {
  // Look up by external reference first — this is the canonical lookup
  const byRef = await fetch(
    `https://api.virtuoussoftware.com/api/Gift/${encodeURIComponent(transactionSource)}/${encodeURIComponent(transactionId)}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );

  if (byRef.ok) {
    return [await byRef.json()]; // The reference is unique — should return one gift
  }

  // If no match by reference, search by Contact + Amount + Date
  const gift = /* the canonical gift you're checking against */;
  const response = await fetch(
    'https://api.virtuoussoftware.com/api/Gift/Query',
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        groups: [
          {
            conditions: [
              { parameter: 'Contact Id', operator: 'Is', value: gift.contactId.toString() },
              { parameter: 'Amount', operator: 'Is', value: gift.amount.toString() },
              { parameter: 'Gift Date', operator: 'Is On', value: gift.giftDate },
            ],
          },
        ],
        skip: 0,
        take: 10,
      }),
    }
  );

  const page = await response.json();
  return page.list;
}
If two or more Gifts appear for the same Contact, amount, and date, they are likely the same donation recorded twice.

Resolving Gift duplicates

Unlike Contacts, Gift duplicate resolution is API-accessible — but the pattern is different from “deleting” the duplicate.

The reversing transaction pattern

To remove a duplicate Gift while preserving the accounting trail, create a reversing transaction rather than deleting the duplicate:
cURL
curl -X POST https://api.virtuoussoftware.com/api/Gift/ReversingTransaction \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "reversedGiftId": 78422,
    "giftDate": "2024-12-16",
    "notes": "Reversing duplicate of Gift 78421 (Stripe ch_3PXyz123)"
  }'
The reversing transaction creates a separate Gift record that offsets the original — the original Gift remains in place, the new reversing Gift cancels its accounting effect, and the net contribution is zero. This preserves the audit trail (you can see both the original duplicate and the offset) while making the donor’s giving total accurate.
The exact request body fields for POST /api/Gift/ReversingTransaction may vary by organization configuration. The example above shows the conceptual pattern; confirm the exact required fields by inspecting the endpoint’s request schema or by checking with Virtuous support before relying on this in production.

When to delete instead

DELETE /api/Gift/{giftId} exists but should be used sparingly. Deleting a Gift removes it from the audit trail entirely — accounting systems that have already reconciled the gift will see a missing record on the next sync. The reversing-transaction pattern is preferred for any gift that has been visible to downstream systems (receipts, accounting exports, reporting). Delete is appropriate only when:
  • The duplicate Gift was created very recently and has not yet been visible in any downstream report or receipt.
  • The customer’s accounting team has confirmed there is no need to preserve the audit trail.
  • The duplicate is unambiguously a clerical error rather than a recorded-twice payment.
In all other cases, use the reversing transaction.

Preventing future duplicates

Detection and resolution are the last line of defense. Prevention is the first.

Always use Transaction endpoints

POST /api/v2/Gift/Transaction and POST /api/Contact/Transaction apply Virtuous’s matching algorithm. POST /api/Gift and POST /api/Contact do not. Use the Transaction variants by default. See Transactions.

Include strong matching signals

For Contact Transactions, the matching algorithm uses signals in priority order: external reference, email, phone, name + address. Include as many as your platform captures.
{
  "referenceSource": "YourPlatform",
  "referenceId": "donor-bw-001",
  "emailType": "Home Email",
  "email": "bruce@wayne.example",
  "phoneType": "Mobile Phone",
  "phone": "555-0100",
  "firstName": "Bruce",
  "lastName": "Wayne",
  "address1": "1007 Mountain Drive",
  "city": "Gotham",
  "state": "NJ",
  "postal": "07001",
  "country": "US"
}
The more signals present, the more likely the matcher correctly resolves to an existing Contact. A Contact Transaction with only firstName and lastName is much more likely to either create a duplicate (no match on existing record) or merge into the wrong record (matching by name alone is ambiguous).

Use stable transactionId for Gifts

transactionId is the idempotency key for Gift submissions. Use your platform’s stable identifier for the donation event — the Stripe charge ID, the donation ID from your platform’s database. Never regenerate transactionId on retry.
JavaScript
// ❌ Wrong — regenerating transactionId on each retry
async function submitGift(donation) {
  const body = {
    transactionSource: 'YourPlatform',
    transactionId: crypto.randomUUID(),    // ❌ different on each call
    // ...
  };
  // If this retries, Virtuous sees each retry as a new gift.
}

// ✅ Correct — using a stable identifier from the donation event itself
async function submitGift(donation) {
  const body = {
    transactionSource: 'YourPlatform',
    transactionId: donation.id,            // ✅ stable across retries
    // ...
  };
}

Validate before direct create

If for some reason your integration must use direct create (POST /api/Contact or POST /api/Gift), wrap it in your own lookup logic. See Create a Contact, Strategy 2: Find-then-create.

Operational practices

Duplicates are a chronic problem in any nonprofit’s donor data, not a one-time fix. Treat duplicate management as an ongoing operational concern:
  • Run the post-create reconciliation query on a schedule. Daily is reasonable for active integrations.
  • Surface duplicates to the customer in your UI rather than hiding them. The customer’s team is the only party who can perform the actual merge.
  • Alert when the duplicate count grows. A sudden spike usually indicates a sync regression — a recent change in your integration is producing duplicates faster than the customer can merge them.
  • Track which integration created each Contact. If your originSegmentCode or referenceSource is consistent on every submission, you can attribute duplicates to your own work vs. other sources.

Where to go next

Reconcile Failed Syncs

The broader treatment of sync failure recovery — including the needs-update bucket where unmatched Transactions land.

Build a Two-Way Sync

How merge events on the Virtuous side propagate back to your platform in a two-way sync.

Transactions

The matching algorithm that prevents most duplicates when used correctly.

Create a Contact

The find-then-create pattern that prevents partner-introduced Contact duplicates.
Last modified on May 21, 2026