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

# Reconcile with CRM+

> The workflow for keeping a partner integration's view of Raise data consistent with its view of CRM+ data — three reconciliation patterns and the operational patterns that keep them running cleanly.

For partner integrations that read data from both Raise and CRM+, reconciliation is the workflow that keeps the two views aligned. The platform-level sync between Raise and CRM+ is eventually consistent — most data flows through within seconds, but occasional records take longer, fail silently, or end up in inconsistent states. Reconciliation is how a partner integration detects and handles those cases.

This page covers when reconciliation is needed, the three patterns that work, and the operational patterns that keep reconciliation running cleanly day-to-day. For the underlying mechanics of how data flows between the products, see [How Raise Data Flows to CRM+](/raise/concepts/data-flow-to-crm).

## When to use this workflow

| Scenario                                                                          | Approach                                                                     |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Integration reads data only from Raise (or only from CRM+)                        | Skip — no reconciliation needed.                                             |
| Integration reads from both products and presents a unified view                  | Use this workflow.                                                           |
| Integration writes to one product and confirms downstream visibility in the other | Use Pattern 2 (dual webhook).                                                |
| Auditing a customer's data integrity across both products                         | Use Pattern 3 (periodic backstop).                                           |
| Initial setup or migration where both products are being aligned                  | Use a combination — Pattern 3 for initial sweep, Pattern 1 or 2 for ongoing. |

For integrations that touch only one product (a Raise-only fundraising integration, a CRM+-only stewardship tool), reconciliation across the two isn't needed. The remaining patterns assume both products are in scope.

***

## What the integration is reconciling

The three reconcilable resource mappings (from [How Raise Data Flows to CRM+](/raise/concepts/data-flow-to-crm#what-flows-where)):

| Raise resource | CRM+ resource | What "consistent" means                                                                         |
| -------------- | ------------- | ----------------------------------------------------------------------------------------------- |
| Donor          | Contact       | Both records exist; the Raise Donor's `crmKey` matches the CRM+ Contact's ID.                   |
| Gift           | Gift          | Both records exist; the CRM+ Gift carries identifying metadata pointing back to the Raise Gift. |
| RecurringGift  | RecurringGift | Both records exist; the schedule's `crmKey` matches the CRM+ RecurringGift's ID.                |

A reconciliation discrepancy is a record that exists on one side but not the other, or a pair where the linkage is broken.

***

## Pattern 1: `crmKey` as the join key

The cheapest reconciliation pattern. For any given Raise record, the `crmKey` field tells you whether it has been synced and what its corresponding CRM+ record is.

### Quick consistency check

```javascript JavaScript theme={null}
async function checkDonorConsistency(raiseDonorId) {
  // 1. Read the Raise Donor
  const raiseDonor = await fetch(
    `https://prod-api.raisedonors.com/api/Donor/${raiseDonorId}`,
    { headers: { Authorization: `Bearer ${process.env.RAISE_API_TOKEN}` } }
  ).then((r) => r.json());

  if (!raiseDonor.crmKey) {
    return { state: 'not_yet_synced', raiseDonor };
  }

  // 2. Try to find the corresponding CRM+ Contact
  const crmResponse = await fetch(
    `https://api.virtuoussoftware.com/api/Contact/${raiseDonor.crmKey}`,
    { headers: { Authorization: `Bearer ${process.env.CRM_API_TOKEN}` } }
  );

  if (crmResponse.status === 404) {
    return { state: 'orphaned_crm_key', raiseDonor };
  }

  if (!crmResponse.ok) {
    return { state: 'crm_unreachable', raiseDonor };
  }

  const crmContact = await crmResponse.json();
  return { state: 'consistent', raiseDonor, crmContact };
}
```

The three non-consistent states this surfaces:

| State              | What it means                                                                                  | Typical response                                                                                                               |
| ------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `not_yet_synced`   | Raise record exists; `crmKey` is null. Sync hasn't completed yet or sync is disabled.          | Re-check after a sync-lag window (typically minutes). Persistent absence may indicate `canSync: false` on the parent Campaign. |
| `orphaned_crm_key` | Raise record has a `crmKey` but the CRM+ record at that ID doesn't exist. The link is broken.  | Coordinate with the customer's admin team — the CRM+ record may have been deleted, or the link was set incorrectly.            |
| `crm_unreachable`  | CRM+ couldn't be queried (auth error, network issue, etc.). Not a reconciliation issue per se. | Retry; alert on persistent unreachability.                                                                                     |

### Bulk consistency check

For checking many donors at once, batch the lookups:

```javascript JavaScript theme={null}
async function bulkCheckDonorConsistency(raiseDonors) {
  const withKey = raiseDonors.filter((d) => d.crmKey);
  const withoutKey = raiseDonors.filter((d) => !d.crmKey);

  // Check each donor with a crmKey
  const consistencyChecks = await Promise.all(
    withKey.map(async (raiseDonor) => {
      const crmResponse = await fetch(
        `https://api.virtuoussoftware.com/api/Contact/${raiseDonor.crmKey}`,
        { headers: { Authorization: `Bearer ${process.env.CRM_API_TOKEN}` } }
      );
      if (crmResponse.status === 404) {
        return { state: 'orphaned_crm_key', raiseDonor };
      }
      if (crmResponse.ok) {
        return { state: 'consistent', raiseDonor };
      }
      return { state: 'crm_unreachable', raiseDonor };
    })
  );

  return {
    notYetSynced: withoutKey,
    consistencyChecks,
  };
}
```

For very large donor sets, throttle the concurrent CRM+ lookups — `Promise.all` of thousands of fetches will hit rate limits or overwhelm the network. Use a controlled-concurrency pattern (e.g., a queue with N workers) and pace per-customer per-product limits.

***

## Pattern 2: dual webhook subscription with deduplication

For real-time reconciliation, subscribe to webhook events from both products and deduplicate at the integration level.

### The dual subscription

Set up one subscription per product, each pointing to your integration's webhook endpoint:

```bash cURL theme={null}
# Raise side
curl -X POST https://prod-api.raisedonors.com/api/Webhook \
  -H "Authorization: Bearer YOUR_RAISE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Cross-product reconciliation",
    "notificationUrl": "https://partner.example.com/raise-webhooks",
    "eventTypesList": [10, 11],
    "format": 1,
    "status": 1,
    "securityToken": "raise-shared-secret"
  }'

# CRM+ side (URL/auth differ — refer to CRM+ webhook docs)
```

### The deduplication

When events arrive from both products for the same logical entity (a Raise Gift and the CRM+ Gift that's its synced counterpart), the integration treats the first arrival as the primary signal and the second as confirmation:

```javascript JavaScript theme={null}
async function handleRaiseGiftEvent(payload) {
  const raiseGift = payload;

  // Look for an existing record in the integration's database
  const existing = await db.findByRaiseGiftId(raiseGift.id);

  if (existing) {
    // We've already seen this through another path — update with Raise data
    await db.update(existing.id, {
      raiseGiftSeenAt: new Date(),
      raiseGiftAmount: raiseGift.amount,
      raiseCrmKey: raiseGift.crmKey,
    });
  } else {
    // First time seeing this gift — create a new record
    await db.create({
      raiseGiftId: raiseGift.id,
      raiseGiftSeenAt: new Date(),
      raiseGiftAmount: raiseGift.amount,
      donorEmail: raiseGift.donor?.email,
    });
  }
}

async function handleCrmGiftEvent(payload) {
  const crmGift = payload;

  // The CRM+ Gift carries a reference back to its source Raise Gift
  // (the exact field name depends on the CRM+ event payload shape)
  const sourceRaiseGiftId = crmGift.transactionSource === 'raise' ? crmGift.sourceId : null;

  if (sourceRaiseGiftId) {
    const existing = await db.findByRaiseGiftId(sourceRaiseGiftId);
    if (existing) {
      // Confirm CRM+ side received it
      await db.update(existing.id, {
        crmGiftId: crmGift.id,
        crmGiftSeenAt: new Date(),
      });
    } else {
      // The CRM+ event arrived before the Raise event — unusual but possible
      await db.create({
        raiseGiftId: sourceRaiseGiftId,
        crmGiftId: crmGift.id,
        crmGiftSeenAt: new Date(),
      });
    }
  }
}
```

The integration's database ends up with one row per gift, with both `raiseGiftSeenAt` and `crmGiftSeenAt` populated when both events have arrived.

### Detecting one-sided arrivals

The most valuable signal from this pattern: gifts that arrive from one product but never confirm from the other within an expected window. A periodic check:

```javascript JavaScript theme={null}
async function findOneSidedGifts(maxLagMinutes = 60) {
  const cutoff = new Date(Date.now() - maxLagMinutes * 60 * 1000);

  // Raise-side gifts that haven't confirmed in CRM+
  const onlyInRaise = await db.find({
    raiseGiftSeenAt: { lt: cutoff },
    crmGiftSeenAt: null,
  });

  // CRM+-side gifts that haven't confirmed in Raise (unusual but possible)
  const onlyInCrm = await db.find({
    crmGiftSeenAt: { lt: cutoff },
    raiseGiftSeenAt: null,
  });

  return { onlyInRaise, onlyInCrm };
}
```

`onlyInRaise` gifts older than an hour likely indicate a sync issue worth investigating. The customer's admin team can use the gift ID to look up its sync state in the platform tools.

***

## Pattern 3: periodic reconciliation backstop

For high-confidence reconciliation independent of webhook reliability, run a periodic backstop that queries both sides and compares.

### The full daily reconciliation

```javascript JavaScript theme={null}
async function reconcileGiftsForDay(date) {
  const startOfDay = new Date(date).toISOString().split('T')[0];
  const endOfDay = new Date(date.getTime() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];

  // 1. Pull all Raise gifts for the day
  const raiseGifts = await streamGiftsCursor([
    {
      conditions: [
        { parameter: 'date', operator: GT_OPERATOR, value: startOfDay },
        { parameter: 'date', operator: LT_OPERATOR, value: endOfDay },
      ],
      conjunct: AND_CONJUNCT,
    },
  ]);

  // 2. Pull all CRM+ gifts from Raise-source for the day
  // (the exact filter syntax depends on CRM+'s API — refer to CRM+ workflow docs)
  const crmGifts = await pullCrmGiftsForDateRange(startOfDay, endOfDay);

  // 3. Index both sides by their cross-reference identifiers
  const raiseByCrmKey = new Map(
    raiseGifts.filter((g) => g.crmKey).map((g) => [g.crmKey, g])
  );
  const crmByRaiseId = new Map(
    crmGifts
      .filter((g) => g.sourceRaiseGiftId)
      .map((g) => [g.sourceRaiseGiftId, g])
  );

  // 4. Identify discrepancies
  const inRaiseNotInCrm = raiseGifts.filter(
    (g) => g.crmKey && !crmByRaiseId.has(g.id)
  );
  const inCrmNotInRaise = crmGifts.filter(
    (g) => g.sourceRaiseGiftId && !raiseByCrmKey.has(g.sourceRaiseGiftId)
  );
  const notYetLinked = raiseGifts.filter((g) => !g.crmKey);

  return {
    totalRaise: raiseGifts.length,
    totalCrm: crmGifts.length,
    inRaiseNotInCrm,
    inCrmNotInRaise,
    notYetLinked,
  };
}
```

### Allowing for sync lag

Don't reconcile the current day during the day itself — running at noon and complaining about gifts created at 11:55 AM that aren't in CRM+ yet produces false positives. Reconcile **yesterday** during today's run:

```javascript JavaScript theme={null}
async function dailyReconciliationJob() {
  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  yesterday.setHours(0, 0, 0, 0);

  const result = await reconcileGiftsForDay(yesterday);

  if (result.inRaiseNotInCrm.length > 0) {
    await alertOps({
      message: `${result.inRaiseNotInCrm.length} Raise gifts missing in CRM+`,
      details: result.inRaiseNotInCrm.map((g) => g.id),
    });
  }

  if (result.notYetLinked.length > 0) {
    // These may be gifts whose Campaign has canSync: false
    await alertOps({
      severity: 'info',
      message: `${result.notYetLinked.length} Raise gifts with no crmKey from yesterday`,
    });
  }

  return result;
}
```

### What "missing in CRM+" actually means

A gift in `inRaiseNotInCrm` after a 24-hour sync window is one of:

| Cause                                                  | Resolution                                                                                                                     |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| Sync genuinely failed                                  | Coordinate with the platform team — they have tools to inspect and retry.                                                      |
| The Campaign has `canSync: false`                      | This is expected behavior — filter these out before alerting. Check `gift.campaign.canSync` on the Raise side.                 |
| The customer doesn't run CRM+                          | Single-product Raise customers will never have `crmKey` populated. Detect this once and skip reconciliation for that customer. |
| The integration is querying CRM+ with the wrong filter | Validate the CRM+ query returns gifts you know are there.                                                                      |

Build the alert to include enough context for ops to investigate without re-running diagnostics. At minimum: the Raise gift ID, the Campaign ID, the gift amount, and the Campaign's `canSync` flag.

***

## Handling discrepancies

The three patterns surface discrepancies, but they don't fix them automatically. The right response depends on the discrepancy type:

### Missing in CRM+ after sync lag

| Step                             | Action                                                                                   |
| -------------------------------- | ---------------------------------------------------------------------------------------- |
| 1. Filter out expected non-syncs | Gifts under campaigns with `canSync: false`. These aren't discrepancies.                 |
| 2. Filter out test-mode          | Test-mode gifts may sync to test-mode CRM+ environments differently — confirm the scope. |
| 3. Surface remaining cases       | Build a daily report or ops alert with the gift IDs and context.                         |
| 4. Coordinate with platform team | The platform may need to manually trigger a sync or fix a data issue.                    |

The partner integration doesn't have a "retry sync" API endpoint to call — the sync mechanism is platform-internal. The integration's role is to detect and surface; the customer's admin team and the platform team handle resolution.

### Orphaned `crmKey`

A Raise record with a `crmKey` pointing at a non-existent CRM+ record means one of:

* The CRM+ record was deleted after sync completed.
* The `crmKey` was set incorrectly (e.g., by a partner integration that seeded a wrong value).

The fix is typically to clear the `crmKey` on the Raise side (via `PATCH /api/Donor/{id}`) and let sync re-establish the linkage — or coordinate with the customer's admin team if the CRM+ record needs to be recreated.

### Persistent inconsistency between fields

A Raise Donor and its CRM+ Contact should have matching identity fields (name, email) once they're linked. If they drift apart — donor's email is updated in Raise but not reflected in CRM+ — the sync should catch up eventually, but partner integrations that detect drift can choose to:

| Strategy                     | Description                                                                                              |
| ---------------------------- | -------------------------------------------------------------------------------------------------------- |
| Wait and recheck             | Drift may resolve as the next sync cycle runs.                                                           |
| Surface for human review     | The customer's admin team can investigate why the sync isn't catching up.                                |
| Treat Raise as authoritative | For partner integrations that read from both, defer to Raise as the canonical source when they disagree. |

The right strategy depends on the integration's purpose. For analytics integrations, deferring to one product as canonical is usually best; for stewardship integrations that need real-time accuracy, surfacing for review is safer.

***

## Operational patterns

### Run reconciliation on a schedule

A daily reconciliation job that runs after sync lag has settled (e.g., 6 AM the next morning) catches most issues with minimal false-positive noise. For higher-stakes integrations, hourly reconciliation with a larger lag window (4 hours) is acceptable.

Don't run reconciliation every few minutes — the per-customer query cost is significant and the false-positive rate from sync lag is high.

### Track reconciliation results over time

Each reconciliation run produces metrics:

* Total Raise records found
* Total CRM+ records found
* Number of in-Raise-not-in-CRM discrepancies
* Number of in-CRM-not-in-Raise discrepancies
* Number of not-yet-linked records

Trend these over time. A baseline of \~0 discrepancies with occasional spikes is normal; a sustained spike indicates a sync issue worth investigating.

### Provide a clear ops dashboard

When discrepancies are detected, ops needs to know:

* Which customer is affected
* The specific record IDs in question
* The amounts involved (gifts, especially)
* The likely cause (canSync false, sync lag, genuine failure)
* A direct link to the records in the Raise admin UI and the CRM+ admin UI

The `crmKeyUrls` field on Raise records is helpful here — it provides the direct URL to the CRM+ record without requiring ops to assemble URLs by ID. See [How Raise Data Flows to CRM+: crmKeyUrls](/raise/concepts/data-flow-to-crm#crmkeyurls).

### Don't auto-remediate without human review

Tempting as it is to write "if discrepancy detected, retry sync" logic, the platform sync isn't partner-controlled and auto-remediation can mask underlying issues. Surface discrepancies for human review; let the customer's admin team and the platform team handle resolution.

***

## A complete reconciliation snippet

Putting it all together as a daily job:

```javascript JavaScript theme={null}
class ReconciliationJob {
  constructor({ raiseToken, crmToken, alerter, metrics }) {
    this.raiseToken = raiseToken;
    this.crmToken = crmToken;
    this.alerter = alerter;
    this.metrics = metrics;
  }

  async runDaily(date) {
    const result = await reconcileGiftsForDay(date);

    // Emit metrics
    this.metrics.gauge('reconcile.raise_total', result.totalRaise);
    this.metrics.gauge('reconcile.crm_total', result.totalCrm);
    this.metrics.gauge('reconcile.missing_in_crm', result.inRaiseNotInCrm.length);
    this.metrics.gauge('reconcile.missing_in_raise', result.inCrmNotInRaise.length);
    this.metrics.gauge('reconcile.not_yet_linked', result.notYetLinked.length);

    // Alert on actionable discrepancies
    const actionable = result.inRaiseNotInCrm.filter(
      (g) => g.canSync !== false && !g.isTestMode
    );
    if (actionable.length > 0) {
      await this.alerter.send({
        severity: 'warning',
        title: `${actionable.length} Raise gifts not found in CRM+`,
        date: date.toISOString().split('T')[0],
        sample: actionable.slice(0, 10).map((g) => ({
          giftId: g.id,
          amount: g.formattedAmount,
          donorEmail: g.donor?.email,
          crmKeyUrl: Object.values(g.crmKeyUrls || {})[0]?.url,
        })),
      });
    }

    return result;
  }
}
```

Run this on a daily cron job (e.g., 6 AM) with `date = yesterday`. Track the metrics over time. Alert on actionable discrepancies but not on `not_yet_linked` or test-mode records.

***

## Where to go next

<CardGroup cols={2}>
  <Card title="How Raise Data Flows to CRM+" icon="arrow-right-arrow-left" href="/raise/concepts/data-flow-to-crm">
    The underlying mechanics of the cross-product sync.
  </Card>

  <Card title="Query Gifts by Filters" icon="filter" href="/raise/workflows/query-gifts-by-filters">
    The Gift-side queries that feed reconciliation.
  </Card>

  <Card title="Query Donors by Filters" icon="user-magnifying-glass" href="/raise/workflows/query-donors-by-filters">
    The Donor-side queries that feed donor-level reconciliation.
  </Card>

  <Card title="Sync Architecture Patterns" icon="diagram-project" href="/raise/best-practices/sync-architecture-patterns">
    The broader architectural patterns for sync-aware integration design.
  </Card>
</CardGroup>
