Skip to main content
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+.

When to use this workflow

ScenarioApproach
Integration reads data only from Raise (or only from CRM+)Skip — no reconciliation needed.
Integration reads from both products and presents a unified viewUse this workflow.
Integration writes to one product and confirms downstream visibility in the otherUse Pattern 2 (dual webhook).
Auditing a customer’s data integrity across both productsUse Pattern 3 (periodic backstop).
Initial setup or migration where both products are being alignedUse 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 resourceCRM+ resourceWhat “consistent” means
DonorContactBoth records exist; the Raise Donor’s crmKey matches the CRM+ Contact’s ID.
GiftGiftBoth records exist; the CRM+ Gift carries identifying metadata pointing back to the Raise Gift.
RecurringGiftRecurringGiftBoth 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
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:
StateWhat it meansTypical response
not_yet_syncedRaise 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_keyRaise 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_unreachableCRM+ 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
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:
cURL
# 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
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
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
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
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:
CauseResolution
Sync genuinely failedCoordinate with the platform team — they have tools to inspect and retry.
The Campaign has canSync: falseThis 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 filterValidate 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

StepAction
1. Filter out expected non-syncsGifts under campaigns with canSync: false. These aren’t discrepancies.
2. Filter out test-modeTest-mode gifts may sync to test-mode CRM+ environments differently — confirm the scope.
3. Surface remaining casesBuild a daily report or ops alert with the gift IDs and context.
4. Coordinate with platform teamThe 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:
StrategyDescription
Wait and recheckDrift may resolve as the next sync cycle runs.
Surface for human reviewThe customer’s admin team can investigate why the sync isn’t catching up.
Treat Raise as authoritativeFor 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.

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

How Raise Data Flows to CRM+

The underlying mechanics of the cross-product sync.

Query Gifts by Filters

The Gift-side queries that feed reconciliation.

Query Donors by Filters

The Donor-side queries that feed donor-level reconciliation.

Sync Architecture Patterns

The broader architectural patterns for sync-aware integration design.
Last modified on May 20, 2026