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

# Handle Duplicate Records

> How duplicates appear in CRM+ despite the Transaction matching algorithm, how to detect them, and what your partner integration can and cannot do to resolve them.

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:

| Cause                                       | Affects                                                                                                                                                                                                               |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Insufficient matching signal**            | Two 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 lookup**            | `POST /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 `transactionId`**                | A Gift Transaction retried with a freshly-generated `transactionId` instead of the original is seen as a new gift by Virtuous.                                                                                        |
| **Concurrent writes from multiple sources** | The 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 type                      | Resolution path                                            | API support                                                                                              |
| ----------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| **Two Contacts for the same donor** | Merge 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 donation** | Create 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](/crm/workflows/create-a-contact#step-2-pre-create-lookup). 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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
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(),
      }
    );
  }
}
```

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

***

## Resolving Contact duplicates

This is where the partner integration's options are constrained.

<Warning>
  **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.
</Warning>

The partner-side workflow:

<Steps>
  <Step title="Detect the likely duplicate set">
    Use Pattern B above (post-create reconciliation query) to surface likely duplicate groups.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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 JavaScript theme={null}
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:

```bash cURL theme={null}
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.

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

### 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](/crm/concepts/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.

```json theme={null}
{
  "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 JavaScript theme={null}
// ❌ 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](/crm/concepts/contacts#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

<CardGroup cols={2}>
  <Card title="Reconcile Failed Syncs" icon="arrows-rotate" href="/crm/workflows/reconcile-failed-syncs">
    The broader treatment of sync failure recovery — including the needs-update bucket where unmatched Transactions land.
  </Card>

  <Card title="Build a Two-Way Sync" icon="arrow-right-arrow-left" href="/crm/workflows/build-a-two-way-sync">
    How merge events on the Virtuous side propagate back to your platform in a two-way sync.
  </Card>

  <Card title="Transactions" icon="arrow-down-to-line" href="/crm/concepts/transactions">
    The matching algorithm that prevents most duplicates when used correctly.
  </Card>

  <Card title="Create a Contact" icon="user-plus" href="/crm/workflows/create-a-contact">
    The find-then-create pattern that prevents partner-introduced Contact duplicates.
  </Card>
</CardGroup>
