Skip to main content
The most valuable insights for many nonprofits come from combining donor and volunteer data. The donor who also volunteers is the most engaged, highest-value supporter. The volunteer who’s never been asked to donate is an untapped opportunity. The lapsed donor who’s still volunteering offers a path back to giving. But this data lives in two separate APIs — VOMO (Volunteer) and CRM+ — built by different teams at different times, with distinct conventions, distinct schemas, and distinct identifiers. This recipe walks through stitching them together: matching strategies, schema mapping, conflict resolution, and the joined-data-store pattern that makes cross-API integration manageable. The recipe combines Volunteer User listing/upsert with CRM+ Contact querying into a unified view.

What you’ll build

A partner integration that:
  • Reads Users from Volunteer and Contacts from CRM+ separately
  • Matches them across the two APIs (email is the primary join key)
  • Stores a joined “person” record in a partner-side data store
  • Surfaces cross-API insights: donor-volunteer overlap, lapsed-donor-active-volunteer flags, volunteer-only outreach lists
  • Handles conflicts where the two APIs disagree about a field’s value

When this recipe fits

ScenarioThis recipe fits
Identify donor-volunteer overlap for stewardship✓ Primary use case
Build “supporters who do both” reports
Power BI dashboards combining giving and serving data
Trigger workflows when a volunteer becomes a donor (or vice versa)
Treat VOMO Users and CRM+ Contacts as the same person, atomically syncedPartially — the APIs don’t enforce this, you build it

The fundamental challenge: two APIs, no shared ID

VOMO and CRM+ don’t share user identifiers. A volunteer with VOMO User ID 12345 might also be CRM+ Contact ID 99876 — but neither API knows about the other’s ID.
APIIdentifierType
Volunteerid (e.g., 12345)integer, snake_case access
CRM+Id (e.g., 99876)integer, PascalCase access
The email address is the only natural join key. Both APIs treat email as a primary lookup field — Volunteer’s upsert matches by email; CRM+ Contact lookup typically goes by email. But email isn’t always reliable:
  • A volunteer may have multiple emails in different systems (work vs. personal)
  • An email change in one system breaks the join with the other
  • Email matching is case-insensitive in both APIs but partners may forget to normalize
For most integrations, email is the right starting point, with explicit handling of the edge cases.

Architecture

Six components:
ComponentPurpose
VOMO User collectorPolls Volunteer; populates the joined store with User data
CRM+ Contact collectorPolls or receives webhooks from CRM+; populates the joined store with Contact data
Joined person storePartner-side database; one row per person with both VOMO and CRM+ data
Cross-API reconciliationPeriodic checks that the two API sides agree
Insights / dashboardsCustomer-facing views that depend on the joined data
The joined store is the integration’s source of truth — both API collectors write into it, and downstream insights read from it.

Step 1: design the joined schema

The joined person record holds data from both APIs plus partner-side metadata:
CREATE TABLE joined_persons (
  customer_id            VARCHAR NOT NULL,
  partner_person_id      VARCHAR NOT NULL,  -- Stable partner-assigned ID

  -- Join key
  email                  VARCHAR NOT NULL,  -- Normalized lowercase

  -- VOMO side
  vomo_user_id           INTEGER,
  vomo_first_name        VARCHAR,
  vomo_last_name         VARCHAR,
  vomo_phone             VARCHAR,
  vomo_user_status       VARCHAR,
  vomo_membership_role   VARCHAR,
  vomo_updated_at        TIMESTAMP,
  vomo_last_synced_at    TIMESTAMP,

  -- CRM+ side
  crm_contact_id         INTEGER,
  crm_first_name         VARCHAR,
  crm_last_name          VARCHAR,
  crm_phone              VARCHAR,
  crm_donor_lifetime_amount  DECIMAL(15, 2),
  crm_last_gift_date     TIMESTAMP,
  crm_updated_at         TIMESTAMP,
  crm_last_synced_at     TIMESTAMP,

  -- Joined / derived
  has_vomo               BOOLEAN DEFAULT false,
  has_crm                BOOLEAN DEFAULT false,
  is_donor_volunteer     BOOLEAN GENERATED ALWAYS AS (has_vomo AND has_crm) STORED,

  -- Metadata
  created_at             TIMESTAMP DEFAULT NOW(),
  updated_at             TIMESTAMP DEFAULT NOW(),

  PRIMARY KEY (customer_id, partner_person_id),
  UNIQUE (customer_id, email)
);

CREATE INDEX joined_persons_by_vomo ON joined_persons (customer_id, vomo_user_id);
CREATE INDEX joined_persons_by_crm ON joined_persons (customer_id, crm_contact_id);
CREATE INDEX joined_persons_overlap ON joined_persons (customer_id, is_donor_volunteer);
Key design decisions:
DecisionRationale
Partner-side stable partner_person_idDecouples from VOMO/CRM+ IDs; survives email changes
Email normalized to lowercasePrevents case-sensitivity issues
Separate field columns per APIAllows tracking which API’s value is authoritative for each field
has_vomo / has_crm flagsCheap filter for “exists in this API” queries
Generated is_donor_volunteerCached overlap flag for fast queries

Step 2: collect from VOMO

Reuses the polling pattern from Sync Users to External System:
JavaScript
class VomoToJoinedStoreCollector {
  constructor({ customerId, token, joinedStore }) {
    this.customerId = customerId;
    this.token = token;
    this.joinedStore = joinedStore;
    this.client = new ThrottledVomoClient({ token, requestsPerSecond: 3 });
  }

  async poll() {
    const lastSync = await this.joinedStore.getVomoCheckpoint(this.customerId);
    const params = new URLSearchParams({ updated_after: lastSync.toISOString() });

    let url = `https://api.vomo.org/v1/users?${params}`;
    let latestSeen = lastSync;

    while (url) {
      const response = await this.client.fetch(url);
      if (!response.ok) throw new Error(`Poll failed: ${response.status}`);

      const page = await response.json();
      for (const user of page.data) {
        await this._upsertJoinedFromVomo(user);

        const u = new Date(user.updated_at);
        if (u > latestSeen) latestSeen = u;
      }

      url = page.links.next;
    }

    await this.joinedStore.setVomoCheckpoint(this.customerId, latestSeen);
  }

  async _upsertJoinedFromVomo(vomoUser) {
    const normalizedEmail = vomoUser.email?.toLowerCase().trim();
    if (!normalizedEmail) return; // Can't join without email

    // Find the partner-side joined record (or create)
    const existing = await this.joinedStore.findByEmail(this.customerId, normalizedEmail);
    const partnerId = existing?.partner_person_id ?? generatePartnerPersonId();

    await this.joinedStore.upsert({
      customer_id: this.customerId,
      partner_person_id: partnerId,
      email: normalizedEmail,
      vomo_user_id: vomoUser.id,
      vomo_first_name: vomoUser.first_name,
      vomo_last_name: vomoUser.last_name,
      vomo_phone: vomoUser.phone,
      vomo_user_status: vomoUser.user_status,
      vomo_membership_role: vomoUser.membership_role,
      vomo_updated_at: new Date(vomoUser.updated_at),
      vomo_last_synced_at: new Date(),
      has_vomo: true,
    });
  }
}
The collector writes only the VOMO-side fields — the CRM+ side is populated by the other collector.

Step 3: collect from CRM+

CRM+ has different conventions but the same goal — populate the joined store with Contact data:
JavaScript
class CrmToJoinedStoreCollector {
  constructor({ customerId, token, joinedStore }) {
    this.customerId = customerId;
    this.token = token;
    this.joinedStore = joinedStore;
  }

  async poll() {
    const lastSync = await this.joinedStore.getCrmCheckpoint(this.customerId);

    // CRM+ uses /api/Contact/Query with PascalCase
    const response = await fetch('https://api.virtuoussoftware.com/api/Contact/Query', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        Skip: 0,
        Take: 100,
        Groups: [
          {
            Conditions: [
              {
                Parameter: 'ModifiedDateTimeUtc',
                Operator: 'GreaterThan',
                Value: lastSync.toISOString(),
              },
            ],
          },
        ],
        SortBy: 'ModifiedDateTimeUtc',
      }),
    });

    const result = await response.json();
    let latestSeen = lastSync;

    for (const contact of result.list ?? []) {
      await this._upsertJoinedFromCrm(contact);
      const u = new Date(contact.ModifiedDateTimeUtc);
      if (u > latestSeen) latestSeen = u;
    }

    // Continue paginating with Skip/Take...

    await this.joinedStore.setCrmCheckpoint(this.customerId, latestSeen);
  }

  async _upsertJoinedFromCrm(crmContact) {
    // CRM+ Contact has individuals; pull primary email
    const primaryEmail = this._getPrimaryEmail(crmContact);
    if (!primaryEmail) return;

    const normalizedEmail = primaryEmail.toLowerCase().trim();

    const existing = await this.joinedStore.findByEmail(this.customerId, normalizedEmail);
    const partnerId = existing?.partner_person_id ?? generatePartnerPersonId();

    await this.joinedStore.upsert({
      customer_id: this.customerId,
      partner_person_id: partnerId,
      email: normalizedEmail,
      crm_contact_id: crmContact.Id,
      crm_first_name: this._getPrimaryFirstName(crmContact),
      crm_last_name: this._getPrimaryLastName(crmContact),
      crm_phone: this._getPrimaryPhone(crmContact),
      crm_donor_lifetime_amount: crmContact.LifetimeGivingAmount ?? 0,
      crm_last_gift_date: crmContact.LastGiftDate ? new Date(crmContact.LastGiftDate) : null,
      crm_updated_at: new Date(crmContact.ModifiedDateTimeUtc),
      crm_last_synced_at: new Date(),
      has_crm: true,
    });
  }

  _getPrimaryEmail(contact) {
    const primary = contact.ContactIndividuals?.find((ci) => ci.IsPrimary);
    if (!primary?.ContactMethods) return null;

    const emailMethod = primary.ContactMethods.find(
      (cm) => cm.Type === 'Home Email' || cm.Type === 'Work Email'
    );
    return emailMethod?.Value ?? null;
  }

  _getPrimaryFirstName(contact) {
    const primary = contact.ContactIndividuals?.find((ci) => ci.IsPrimary);
    return primary?.FirstName ?? null;
  }

  _getPrimaryLastName(contact) {
    const primary = contact.ContactIndividuals?.find((ci) => ci.IsPrimary);
    return primary?.LastName ?? null;
  }

  _getPrimaryPhone(contact) {
    const primary = contact.ContactIndividuals?.find((ci) => ci.IsPrimary);
    const phoneMethod = primary?.ContactMethods?.find(
      (cm) => cm.Type === 'Home Phone' || cm.Type === 'Mobile Phone'
    );
    return phoneMethod?.Value ?? null;
  }
}

The convention difference

Notice the field-name mapping:
Joined storeVOMO sourceCRM+ source
emailemailContactIndividuals[].ContactMethods[].Value (filtered)
vomo_user_id / crm_contact_ididId
vomo_first_name / crm_first_namefirst_nameContactIndividuals[].FirstName (primary)
vomo_phone / crm_phonephone (direct)ContactIndividuals[].ContactMethods[].Value (filtered)
VOMO Users are flat — email and phone are top-level properties. CRM+ Contacts are hierarchical — a Contact has Individuals which have ContactMethods. The collectors normalize both into the joined store’s flat structure.

Step 4: resolve conflicts

When the same person exists in both APIs, the two collectors will populate the same joined_persons row. But they may disagree on field values:
FieldVOMO saysCRM+ saysResolution
First name”Bruce""Bruce W.”Both are valid — pick one source as authoritative
Phone”+15551234567""555.123.4567”Both are the same number; pick canonical format
Last name”Wayne""Wayne-Kane”Different — surface for review
Emailbruce@wayne.examplebruce@wayne.exampleShould match (it’s the join key)

Pick an authoritative source per field

For each potentially-conflicting field, decide which API is authoritative:
JavaScript
const FIELD_AUTHORITY = {
  email: 'vomo',           // VOMO upsert by email — VOMO is canonical
  first_name: 'vomo',
  last_name: 'vomo',
  phone: 'crm',            // CRM+ phone is typically curated for donor outreach
  // ...
};

async function getDisplayValue(joinedRecord, fieldName) {
  const authority = FIELD_AUTHORITY[fieldName];
  const vomoValue = joinedRecord[`vomo_${fieldName}`];
  const crmValue = joinedRecord[`crm_${fieldName}`];

  if (authority === 'vomo') return vomoValue ?? crmValue;
  if (authority === 'crm') return crmValue ?? vomoValue;

  // No authority set — prefer more-recent
  if (joinedRecord.vomo_last_synced_at > joinedRecord.crm_last_synced_at) {
    return vomoValue ?? crmValue;
  }
  return crmValue ?? vomoValue;
}

Surface disagreements for review

For fields where neither side is clearly authoritative (or where a disagreement may indicate a data quality issue), surface for human review:
JavaScript
async function detectConflicts(customerId) {
  const persons = await joinedStore.getAllWithBothSides(customerId);
  const conflicts = [];

  for (const p of persons) {
    if (p.vomo_last_name && p.crm_last_name && p.vomo_last_name !== p.crm_last_name) {
      conflicts.push({
        partner_person_id: p.partner_person_id,
        field: 'last_name',
        vomo_value: p.vomo_last_name,
        crm_value: p.crm_last_name,
      });
    }
    // ... other field checks ...
  }

  if (conflicts.length > 0) {
    await externalSystem.publishConflictsForReview(customerId, conflicts);
  }

  return conflicts;
}
Conflicts aren’t necessarily errors — they’re flags for the customer’s data steward to review.

Step 5: surface cross-API insights

The whole point of joining is the questions you can now answer:

Donor-volunteer overlap

SELECT
  COUNT(*) FILTER (WHERE has_vomo AND has_crm) AS donor_volunteers,
  COUNT(*) FILTER (WHERE has_vomo AND NOT has_crm) AS volunteers_only,
  COUNT(*) FILTER (WHERE NOT has_vomo AND has_crm) AS donors_only,
  COUNT(*) AS total_unique_persons
FROM joined_persons
WHERE customer_id = :customerId;
A simple four-row report often surprises customers — many don’t realize how many of their volunteers also give (or vice versa).

Lapsed donors who are still volunteering

SELECT
  partner_person_id,
  email,
  vomo_first_name AS first_name,
  vomo_last_name AS last_name,
  crm_last_gift_date,
  crm_donor_lifetime_amount
FROM joined_persons
WHERE customer_id = :customerId
  AND has_vomo = true
  AND has_crm = true
  AND crm_last_gift_date < NOW() - INTERVAL '1 year'
ORDER BY crm_donor_lifetime_amount DESC;
A high-value reactivation list: people whose engagement (volunteering) is still active but whose giving has lapsed.

Top volunteers who haven’t been asked to give

SELECT
  jp.partner_person_id,
  jp.email,
  jp.vomo_first_name AS first_name,
  jp.vomo_last_name AS last_name,
  SUM(p.hours) AS lifetime_hours,
  COUNT(*) AS lifetime_participations
FROM joined_persons jp
JOIN participations p ON p.customer_id = jp.customer_id AND p.user_id = jp.vomo_user_id
WHERE jp.customer_id = :customerId
  AND jp.has_vomo = true
  AND (jp.has_crm = false OR jp.crm_donor_lifetime_amount = 0)
  AND p.verified = true
GROUP BY jp.partner_person_id, jp.email, jp.vomo_first_name, jp.vomo_last_name
ORDER BY lifetime_hours DESC
LIMIT 50;
A targeted outreach list combining the participation data (from the hours recipe) with the donor-status data — high-engagement volunteers who have never (or recently never) donated.

Step 6: handle the email-change problem (cross-API edition)

The single biggest data-quality challenge with email-based joining: what happens when someone changes their email in one API but not the other? The scenario:
  1. Bruce Wayne is both a VOMO User (bruce@wayne.example) and a CRM+ Contact (bruce@wayne.example). They’re joined as one partner_person_id.
  2. Bruce updates his email in VOMO to bruce.wayne@wayne.example.
  3. The next VOMO sync sees a “new” user (different email).
  4. The next CRM+ sync sees the existing user (same email).
  5. Now there are two partner_person_ids for Bruce — one for each email.

Detection

JavaScript
async function detectPossibleSamePerson(customerId) {
  // Look for pairs where VOMO user ID matches but emails differ from CRM contact
  // OR where CRM contact ID matches across split records
  const orphans = await joinedStore.getOrphans(customerId);
  // ... matching logic ...
}
The most reliable detection: track per-API IDs over time. If a VOMO User ID that previously joined to partner_person_id X now appears with a different email (and a new partner_person_id Y), the integration has split a single person.

Resolution

JavaScript
async function mergePersons(customerId, primaryId, mergeIntoId) {
  // Move all data from mergeIntoId to primaryId
  await db.query(`
    UPDATE participations SET partner_person_id = $1 WHERE partner_person_id = $2;
  `, [primaryId, mergeIntoId]);
  // ... merge other related data ...

  // Delete the duplicate joined record
  await db.query(`
    DELETE FROM joined_persons WHERE customer_id = $1 AND partner_person_id = $2;
  `, [customerId, mergeIntoId]);

  // Audit the merge
  await db.insert('person_merges', {
    customer_id: customerId,
    primary_id: primaryId,
    merged_id: mergeIntoId,
    reason: 'email_change_split',
    merged_at: new Date(),
  });
}
Merging is destructive and shouldn’t be done automatically without strong signals. The typical pattern:
  1. Detect split candidates daily
  2. Surface them for human review
  3. The customer’s data steward approves merges in a UI
  4. The integration applies merges via the audit-logged operation

Step 7: cross-API reconciliation

Daily reconciliation catches drift between the two APIs:
JavaScript
async function dailyCrossApiReconciliation(customerId) {
  const yesterday = startOfDayUTC(daysAgo(1));

  // 1. Re-pull yesterday's VOMO changes (already in joined store, but verify)
  const vomoYesterday = await listVomoUsersUpdatedAfter(customerId, yesterday);
  for (const user of vomoYesterday) {
    const joined = await joinedStore.findByEmail(customerId, user.email.toLowerCase());
    if (!joined) {
      // Unexpected — yesterday's VOMO user is missing from joined store
      await alertOps({
        severity: 'medium',
        customerId,
        type: 'vomo_user_missing_from_joined',
        vomoUserId: user.id,
      });
    } else if (new Date(joined.vomo_last_synced_at) < new Date(user.updated_at)) {
      // Joined record is stale — re-sync
      await reSyncVomoUserToJoined(customerId, user);
    }
  }

  // 2. Same for CRM+ side
  const crmYesterday = await listCrmContactsModifiedAfter(customerId, yesterday);
  // ... similar logic ...

  // 3. Detect orphan VOMO users (in VOMO but not joined to CRM+)
  // 4. Detect orphan CRM+ contacts (in CRM+ but not joined to VOMO)
  // These aren't bugs — they're real-world separations. But track them for reporting.
}
Reconciliation catches both bugs (records missing from joined store) and real-world phenomena (the actual donor-volunteer overlap rate).

Things to watch for

The phone format mismatch

VOMO often stores phone numbers in one format (e.g., +15551234567); CRM+ may store them differently (e.g., (555) 123-4567). They’re the same number but won’t match by string comparison. For phone-based joining or comparison, normalize both to a canonical format (E.164 is the standard).

Different update cadences

VOMO and CRM+ have different update cadences in practice. VOMO Users change a few times per week per active user; CRM+ Contacts change more often (every gift creates a modification). The joined store sees both — but the lag between them can be hours. For workflows triggered by “donor-volunteer overlap detected,” wait a few hours after a new VOMO User appears before declaring “this person is a volunteer-only” — the CRM+ side may still be catching up.

Multi-API webhook complexity

If the partner integration uses CRM+ webhooks for change detection (CRM+ has them; VOMO doesn’t), the two halves of the integration operate on different cadences:
  • CRM+ side: webhook-driven, near-real-time
  • VOMO side: polling, 15-30 minute lag
This is fine for most workloads but can produce momentary inconsistencies in the joined store. Audit pipelines handle this automatically — by the next polling cycle, things converge.

Token isolation

The two APIs use different tokens issued by different customer-side processes. Don’t conflate them:
JavaScript
async function callBothApis(customerId, vomoOperation, crmOperation) {
  const vomoToken = await credentials.getVomoToken(customerId);
  const crmToken = await credentials.getCrmToken(customerId);

  // Make these calls independently — different services, different tokens
  const [vomoResult, crmResult] = await Promise.all([
    vomoOperation(vomoToken),
    crmOperation(crmToken),
  ]);

  return { vomoResult, crmResult };
}
A token issue in one API shouldn’t disable the other — the joined integration should degrade gracefully when one half is unavailable.

Pagination conventions are different

VOMO uses page numbers; CRM+ uses Skip/Take offsets. Don’t try to share pagination code — keep the two API clients independent.

Schema evolution risk

Both APIs evolve independently. A field name change in one doesn’t break the other; but a change in either may break the joined integration. Build defensive parsing on both sides — see Versioning and Backward Compatibility.

What you’ve built

After this recipe:
  • ✅ A joined person store keyed by partner-assigned IDs
  • ✅ Independent collectors for VOMO Users and CRM+ Contacts
  • ✅ Email-based matching with normalization and conflict detection
  • ✅ Per-field authority resolution
  • ✅ Cross-API insight queries (overlap, lapsed donors, top non-donor volunteers)
  • ✅ Email-change-driven split detection and merge workflow
  • ✅ Daily reconciliation across both APIs
This is the foundation for any cross-API workflow combining donor and volunteer data — and arguably the highest-value insights a Virtuous-platform customer can get from a partner integration.

Where to go next

Build a Volunteer Self-Service Portal

The companion product-shape recipe — a customer-facing portal using this data.

Sync Users to External System

The foundational sync pattern this recipe extends.

Report on Volunteer Hours

The participation aggregation that powers volunteer-side insights.

The Volunteer Data Model

The Volunteer data model context.
Last modified on May 22, 2026