Skip to main content
When a new customer first connects your integration to their Virtuous organization, they typically have months or years of historical donation data sitting in your platform that should appear in Virtuous. This recipe covers the end-to-end process for that initial bulk load: the pre-flight planning that prevents most mistakes, the validation passes that catch errors before they hit Virtuous, the throttled submission that respects rate limits, and the reconciliation that confirms the load completed correctly. The architecture reuses the patterns from Sync External Donations into Virtuous. The differences from steady-state sync are scale, sequencing, and customer coordination — not architecture.

Why historical imports are different

Steady-state sync handles donations one at a time as they happen. Historical imports submit thousands or tens of thousands of donations in a compressed window. Three things change at that scale:
ConcernSteady-stateHistorical import
Rate limit pressureSubmissions are paced by donor activity — typically dozens to low hundreds per hour.Submissions can hit the 1,500/hour limit immediately if not throttled.
Webhook waveA handful of giftCreate events at a time.Thousands of giftCreate events when the nightly batch processes the import.
Customer-visible impactEach gift appears as it occurs — invisible in the UI’s “recent activity” view.A wall of historical gifts appears at once in the customer’s UI, dashboards, and reports — confusing without communication.
Data quality falloutEdge cases surface gradually.Edge cases surface in bulk during validation, then resurface in bulk as needs-update fallout.
A historical import that’s well-planned takes a day or two of preparation, a few overnight load runs, and a week of reconciliation. A poorly-planned one produces duplicates, surprises the customer’s team, and leaves a needs-update queue that takes weeks to drain.

Phase 1: pre-flight planning

Before writing any import code, work through these decisions with the customer’s team.

Define the date window

What’s the earliest gift date that should appear in Virtuous? Options:
  • Everything in your platform. Captures the full donor history but increases load size and may include data the customer considers obsolete.
  • From a specific cutoff date. Common choices: the customer’s last fiscal year start, the date of their previous CRM migration, the date your platform was first deployed for them.
  • Only gifts from the current fiscal year. Smallest load but loses historical context.
The right answer depends on the customer’s reporting needs. Most customers want at least 1–2 fiscal years of history; longer windows are appropriate for endowment-focused organizations or long-term donor research.

Map Projects and Campaigns

Every gift you load needs a projectCode. Walk through every Project referenced in your historical data and map it to a current Virtuous Project:
Your platform’s project labelMaps to Virtuous projectCodeNotes
”General Fund”GEN-FUNDCustomer’s standard general fund.
”Clean Water 2022”CLEAN-WATERCustomer consolidated annual campaigns into one Project.
”Holiday Match 2021”GEN-FUNDCustomer doesn’t track this anymore — fall back to general.
”International”needs-updateCustomer wants a manual review for these.
Persist this mapping in your import configuration. Any gift whose project mapping is unresolved should fail validation, not silently fall back to a default — silent fallback hides import errors and leaves the customer’s analytics misleading.

Decide on receipt vs. gift date

For each gift in your platform, you’ll be writing one giftDate value to Virtuous. Two common interpretations:
  • Donor-action date. The date the donor made the donation (the day they clicked Donate). Matches giftDate in Virtuous’s normal usage.
  • Settlement date. The date the payment cleared in the customer’s bank — useful for accounting reconciliation but typically several days after the donor’s action.
The default is the donor-action date. Confirm with the customer’s accounting team if they have a strong preference. Don’t try to support both — picking one and using it consistently makes reconciliation tractable.

Plan the batch label

Every Gift Transaction has a batch field — a free-text label that groups gifts together for the customer’s organization. For historical imports, use a clear batch label like Historical-Import-2024-12 that lets the customer’s team filter the imported gifts in the Virtuous UI and lets your reconciliation queries scope to just the imported set.

Communicate with the customer

Before kicking off the load, send the customer’s team a brief explaining:
  • The total number of gifts you’ll load.
  • The expected timeframe (overnight, multi-night, etc.).
  • That a wall of giftCreate events will arrive in their dashboards over the following days as the nightly batch processes the imports.
  • The validation report you’ll produce and how to interpret it.
  • The reconciliation report you’ll produce after the load completes.
A surprised customer is the most common cause of an import being rolled back. A prepared customer treats the wall of gifts as expected and validates the data on their end.

Phase 2: validation passes

Run two validation passes before submitting anything to Virtuous. The goal is to surface every category of problem in a single batch so the customer can review and approve, rather than discovering problems gradually during the load.

Pass 1: structural validation

Iterate every historical gift and check that the required fields are present and well-formed:
JavaScript
async function validateGiftStructure(gift) {
  const problems = [];

  if (!gift.donorPlatformId) problems.push('Missing donor identifier');
  if (!gift.amount || gift.amount <= 0) problems.push(`Invalid amount: ${gift.amount}`);
  if (!gift.giftDate) problems.push('Missing gift date');
  if (!gift.platformDonationId) problems.push('Missing donation identifier');

  // Project must map to a known Virtuous projectCode
  const mappedCode = projectMapping[gift.projectLabel];
  if (!mappedCode) problems.push(`Unmapped project: ${gift.projectLabel}`);
  if (mappedCode === 'needs-update') {
    problems.push(`Project ${gift.projectLabel} requires manual review`);
  }

  // Email and name must be present for the embedded contact data
  if (!gift.donor.email && !gift.donor.referenceId) {
    problems.push('Donor has no email and no reference ID — matching will likely fail');
  }

  return problems;
}
Run this against every historical gift and produce a validation report:
Historical Import Validation Report
====================================
Total gifts in window: 24,182
  Valid: 23,847 (98.6%)
  Invalid: 335 (1.4%)

Breakdown by problem:
  Unmapped project: 287
  Donor missing email and reference ID: 41
  Invalid amount: 7

Project codes by gift count:
  CLEAN-WATER: 8,432
  EDUCATION: 7,118
  GEN-FUND: 6,201
  ENDOWMENT: 2,096
Share this report with the customer’s team. Resolve the project mapping, get clarification on the donors missing both signals, and decide what to do with invalid amounts (typically: skip them and surface separately for manual entry).

Pass 2: contact resolution

For each donor in the import set, attempt to resolve their existing Virtuous Contact. This catches the donors who already exist (so the import will merge rather than create) and surfaces donors whose existence is unclear.
JavaScript
async function preflightResolveContact(donor, token) {
  // Try by reference first — strongest signal
  if (donor.platformId) {
    const byRef = await fetch(
      `https://api.virtuoussoftware.com/api/Contact/Find?referenceSource=YourPlatform&referenceId=${encodeURIComponent(donor.platformId)}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    if (byRef.ok) return { match: 'reference', contact: await byRef.json() };
  }

  // Try by email
  if (donor.email) {
    const byEmail = await fetch(
      `https://api.virtuoussoftware.com/api/Contact/Find?email=${encodeURIComponent(donor.email)}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    if (byEmail.ok) return { match: 'email', contact: await byEmail.json() };
  }

  return { match: 'none' };
}
Categorize each donor:
  • Matched by reference. Existing Contact will be matched cleanly. No risk of duplicate.
  • Matched by email. Existing Contact will be matched, but the import will add your referenceSource/referenceId to the existing record. Confirm this is what the customer wants.
  • No match. A new Contact will be created. These are the donors that produce contactCreate events during the import.
The output is a second validation report covering contact resolution. For very large imports, this pass can itself be rate-limit-sensitive — each unique donor produces one lookup. Throttle accordingly.

Phase 3: the load

With validation complete and the customer informed, execute the load.

Throttling

The CRM+ rate limit is 1,500 requests per hour per Virtuous organization — see Rate Limits. A historical import of 24,000 gifts requires at least 16 hours of submissions even at the maximum rate. Two patterns keep the load within the limit while leaving headroom for the customer’s other integrations:
JavaScript
const TARGET_RATE_PER_HOUR = 1200;  // Leave headroom below the 1500/hour limit
const MIN_INTERVAL_MS = (60 * 60 * 1000) / TARGET_RATE_PER_HOUR;  // ~3 seconds

async function runImportLoad(token) {
  const pending = await loadPendingImportGifts();

  for (const gift of pending) {
    const startTime = Date.now();

    try {
      await submitGiftTransaction(gift, token);
      await markGiftSubmitted(gift);
    } catch (err) {
      await markGiftFailed(gift, err.message);
    }

    // Throttle: wait until we're at least MIN_INTERVAL_MS past startTime
    const elapsed = Date.now() - startTime;
    if (elapsed < MIN_INTERVAL_MS) {
      await sleep(MIN_INTERVAL_MS - elapsed);
    }
  }
}
Two patterns this gets right:
  • Target below the limit. Aiming for 1,200/hour instead of 1,500 leaves 20% headroom for the customer’s other integrations (steady-state donation sync, manual user actions, other partner integrations).
  • Throttle per-request, not per-batch. Submitting 1,000 requests in 30 seconds then idling for 30 minutes burns the rate limit in bursts and produces inconsistent latency on retries. Pace evenly.
For multi-night imports, run the load during off-peak hours (overnight in the customer’s timezone) when the steady-state integration traffic is lower.

Resumability

A 16-hour load needs to be resumable from interruptions — network blips, your worker restarting, the customer pausing for review. Persist progress at each submission:
CREATE TABLE historical_import_state (
  platform_donation_id TEXT PRIMARY KEY,
  customer_id TEXT NOT NULL,
  status TEXT NOT NULL,  -- 'pending' | 'submitted' | 'confirmed' | 'failed' | 'skipped'
  submitted_at TIMESTAMPTZ,
  confirmed_at TIMESTAMPTZ,
  virtuous_gift_id INTEGER,
  last_error TEXT,
  payload JSONB NOT NULL  -- The full Gift Transaction body, for replay
);
The worker reads status = 'pending' records in order, submits, and updates status after each. An interrupted load resumes by re-reading the pending set from where it left off — no records resubmit unless they were never marked submitted.

Idempotency through transactionId

As with steady-state sync, use a stable transactionId derived from your platform’s identifier. If a single submission’s success was acknowledged but lost in transit, the retry produces no duplicate — Virtuous’s matching algorithm recognizes the same transactionSource/transactionId pair and resolves to the same Transaction.
JavaScript
const transactionPayload = {
  transactionSource: 'YourPlatform',
  transactionId: gift.platformDonationId,  // Stable across all retries
  // ...
};

Phase 4: post-load reconciliation

After the load completes, run a full reconciliation pass before declaring the import done.

Confirm the count

Count the gifts you intended to load vs. the gifts that appear in Virtuous:
JavaScript
async function reconcileImportCount(batchLabel, token) {
  // Fetch all Gifts with our batch label
  const virtuousGifts = await queryAllPages({
    groups: [
      { conditions: [{ parameter: 'Batch', operator: 'Is', value: batchLabel }] },
    ],
    sortBy: 'GiftDate',
  }, token);

  const submittedCount = await db.historical_import_state.count({
    customer_id: customerId,
    status: 'submitted',
  });

  console.log(`Submitted: ${submittedCount}`);
  console.log(`In Virtuous with batch '${batchLabel}': ${virtuousGifts.length}`);

  // Expect submittedCount === virtuousGifts.length minus any needs-update records
}
Discrepancies indicate Transactions stuck in needs-update or webhook deliveries that never arrived. Use the patterns from Reconcile Failed Syncs to investigate.

Confirm the sums

For each Project mapped in the import, sum the imported gift amounts and compare against your platform’s totals:
Reconciliation Summary
======================
CLEAN-WATER:
  Your platform total: $487,213.50
  Virtuous total (batch=Historical-Import-2024-12): $487,213.50
  Match: ✓

EDUCATION:
  Your platform total: $312,818.00
  Virtuous total (batch=Historical-Import-2024-12): $312,768.00
  Match: ✗ (delta: $50.00 — 2 gifts unaccounted for)

GEN-FUND:
  Your platform total: $156,902.00
  Virtuous total (batch=Historical-Import-2024-12): $156,902.00
  Match: ✓
Sum-level reconciliation catches discrepancies that count-level reconciliation misses — for example, if two gifts merged into one through unexpected matching, the counts diverge by one but the sums tell you the financial impact.

Confirm the donor side

Run a separate reconciliation against contact creation:
JavaScript
// Count distinct donors in the import set
const importDonorCount = new Set(importGifts.map((g) => g.donorPlatformId)).size;

// Count Contacts in Virtuous referencing your platform that were created
// during the import window
const newContactCount = await queryContactsCreatedInWindow(
  importStartTime,
  importEndTime,
  'YourPlatform',
  token
);

console.log(`Distinct donors in import: ${importDonorCount}`);
console.log(`New Contacts created during import: ${newContactCount}`);
// Difference should equal the count of donors who already existed in Virtuous

Customer sign-off

Send the customer a final reconciliation report and request explicit sign-off before treating the imported gifts as part of normal data. Most customers want the import in a holding state — visible but not yet in receipts or year-end statements — until their accounting team has validated the totals. The batch label makes this easy: configure the customer’s receipt and statement workflows to exclude Historical-Import-* batches until the import is confirmed, then enable the inclusion once they sign off.

Operational considerations

Pausing the load

If the customer requests a pause mid-load, the worker stops processing new records but lets in-flight submissions complete. Resume by restarting the worker — it picks up from the next pending record. Do not deactivate the Virtuous webhook subscription during a pause — the nightly batch is processing the gifts you already submitted, and you need the giftCreate events to confirm them.

Handling needs-update fallout

A typical historical import produces a needs-update queue larger than steady-state sync — gifts whose embedded contact data couldn’t be cleanly matched. Plan for the customer’s team to spend several hours over the week following the import resolving the bucket. For very large imports (50,000+ gifts), the needs-update fallout can be days of customer-team work. Mitigate by improving the matching signal in pre-flight (Pass 2 above) — every donor resolved before submission is one fewer needs-update item.

Logging the import for audit

Every imported gift’s submission, status transitions, and final disposition should be logged. Customers occasionally need to demonstrate that a historical gift was imported (vs. manually entered) — typically during audit or compliance review. The batch label is the first line of audit; a detailed log in your historical_import_state table is the second.

End-to-end checklist

Before considering a historical import complete, confirm:
  • Validation Pass 1 (structural) ran and the customer reviewed the report.
  • Validation Pass 2 (contact resolution) ran and the customer approved the categorization.
  • The Virtuous batch label is consistent across every submitted gift.
  • The load throttled below the rate limit and left headroom for steady-state traffic.
  • The load is resumable from any interruption.
  • Post-load count reconciliation matches.
  • Post-load sum reconciliation matches per Project.
  • Donor-side reconciliation matches.
  • Needs-update fallout is surfaced to the customer’s team with a resolution path.
  • Customer signed off on the imported data before it flows into receipts or statements.

Where to go next

Stripe to Virtuous CRM

The steady-state sync recipe that complements this historical import for ongoing donations.

Reconcile Failed Syncs

Handle the needs-update fallout and any other discrepancies surfaced during reconciliation.

Sync External Donations into Virtuous

The general architecture this historical import reuses.

Rate Limits

The constraint that drives the throttling pattern in Phase 3.
Last modified on May 21, 2026