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
| Scenario | This 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 synced | Partially — 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.| API | Identifier | Type |
|---|---|---|
| Volunteer | id (e.g., 12345) | integer, snake_case access |
| CRM+ | Id (e.g., 99876) | integer, PascalCase access |
- 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
Architecture
Six components:| Component | Purpose |
|---|---|
| VOMO User collector | Polls Volunteer; populates the joined store with User data |
| CRM+ Contact collector | Polls or receives webhooks from CRM+; populates the joined store with Contact data |
| Joined person store | Partner-side database; one row per person with both VOMO and CRM+ data |
| Cross-API reconciliation | Periodic checks that the two API sides agree |
| Insights / dashboards | Customer-facing views that depend on the joined data |
Step 1: design the joined schema
The joined person record holds data from both APIs plus partner-side metadata:| Decision | Rationale |
|---|---|
Partner-side stable partner_person_id | Decouples from VOMO/CRM+ IDs; survives email changes |
| Email normalized to lowercase | Prevents case-sensitivity issues |
| Separate field columns per API | Allows tracking which API’s value is authoritative for each field |
has_vomo / has_crm flags | Cheap filter for “exists in this API” queries |
Generated is_donor_volunteer | Cached overlap flag for fast queries |
Step 2: collect from VOMO
Reuses the polling pattern from Sync Users to External System:JavaScript
Step 3: collect from CRM+
CRM+ has different conventions but the same goal — populate the joined store with Contact data:JavaScript
The convention difference
Notice the field-name mapping:| Joined store | VOMO source | CRM+ source |
|---|---|---|
email | email | ContactIndividuals[].ContactMethods[].Value (filtered) |
vomo_user_id / crm_contact_id | id | Id |
vomo_first_name / crm_first_name | first_name | ContactIndividuals[].FirstName (primary) |
vomo_phone / crm_phone | phone (direct) | ContactIndividuals[].ContactMethods[].Value (filtered) |
Step 4: resolve conflicts
When the same person exists in both APIs, the two collectors will populate the samejoined_persons row. But they may disagree on field values:
| Field | VOMO says | CRM+ says | Resolution |
|---|---|---|---|
| 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 |
| bruce@wayne.example | bruce@wayne.example | Should match (it’s the join key) |
Pick an authoritative source per field
For each potentially-conflicting field, decide which API is authoritative:JavaScript
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
Step 5: surface cross-API insights
The whole point of joining is the questions you can now answer:Donor-volunteer overlap
Lapsed donors who are still volunteering
Top volunteers who haven’t been asked to give
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:- Bruce Wayne is both a VOMO User (
bruce@wayne.example) and a CRM+ Contact (bruce@wayne.example). They’re joined as onepartner_person_id. - Bruce updates his email in VOMO to
bruce.wayne@wayne.example. - The next VOMO sync sees a “new” user (different email).
- The next CRM+ sync sees the existing user (same email).
- Now there are two
partner_person_ids for Bruce — one for each email.
Detection
JavaScript
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
- Detect split candidates daily
- Surface them for human review
- The customer’s data steward approves merges in a UI
- The integration applies merges via the audit-logged operation
Step 7: cross-API reconciliation
Daily reconciliation catches drift between the two APIs:JavaScript
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
Token isolation
The two APIs use different tokens issued by different customer-side processes. Don’t conflate them:JavaScript
Pagination conventions are different
VOMO usespage 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
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.