Integration Nuances
Integration Nuances
This page documents behaviors of the WeGive Virtuous integration that arise from the shape of the Virtuous API itself, not from WeGive implementation choices. Understanding them helps explain why certain things sync the way they do — and why a small number of operations cannot be reflected at all.
Archived Contacts Are Not Reflected in WeGive
When a contact is archived in Virtuous (via Virtuous’s Archive action, or PUT /api/Contact/Archive/:contactId), that archive is not propagated to WeGive. The corresponding WeGive donor remains active until WeGive next tries to push an update for it, at which point the API responds 404 and WeGive surfaces a hard sync failure for that donor (the customer’s archive intent is then honored — WeGive does not silently recreate the contact).
This is a hard constraint imposed by the Virtuous API, not a WeGive product decision. The sections below document every avenue we evaluated for detecting archives and why each one is blocked.
What “reflecting archives” would require
To mirror Virtuous archive state into WeGive, we would need at least one of:
- (A) Push signal — a webhook that fires when a contact is archived or unarchived.
- (B) Pull signal, event-shaped — a query for “contacts archived since timestamp X.”
- (C) Pull signal, state-shaped on a scannable endpoint — an
isArchivedfield on a paginated list response we can walk within the API rate limit. - (D) Reactive signal, disambiguating — a per-record probe whose response distinguishes archive from delete from individual-only delete.
We evaluated each below.
Avenue A — Webhooks (push)
The POST /api/Webhook request body enumerates every supported event flag:
contactCreate, contactUpdategiftCreate, giftUpdate, giftDeleteprojectCreate, projectUpdate, projectDeleteformSubmissioncontactNoteCreate, contactNoteUpdate, contactNoteDeleteeventCreate, eventUpdate, eventDeleteThere is no contactArchive, contactUnarchive, or contactDelete event. Gifts, projects, notes, and events each have explicit delete events; contacts deliberately do not. Archive operations also do not reliably fire contactUpdate, and the contactUpdate payload would not carry archive state even if they did (see Avenue C).
Result: No push signal exists.
Avenue B — Time-bounded archive feed (event-shaped pull)
POST /api/Contact/Query and POST /api/Contact/Query/FullContact accept a single boolean includeArchived in the request body. It toggles whether archived rows appear in the result set — it is not a filter for “only archived” or “archived since X.”
Contact records carry createDateTimeUtc and modifiedDateTimeUtc, but no archivedDateTimeUtc is exposed anywhere in the read schema, so an archive transition feed cannot even be derived by filtering on modified time.
Result: No event-shaped archive feed is available.
Avenue C — isArchived field on a scannable endpoint (state-shaped pull)
This is the only avenue that would be theoretically scalable under Virtuous’s 1,500 requests per minute rate limit: walk the corpus once via a paginated endpoint and read archive state per record.
Every documented Contact read endpoint was checked:
| Endpoint | isArchived in response? |
|---|---|
GET /api/Contact/:contactId | No |
GET /api/Contact/:referenceSource/:referenceId | No |
GET /api/ContactIndividual/:id | No |
POST /api/Contact/Query (abbreviated, paginated, take=1000) | No |
POST /api/Contact/Query/FullContact (paginated, take=1000) | No |
GET /api/Contact/Find (global search) | Yes, per hit |
Where isArchived does appear in the API surface:
- Write payloads —
POST /ContactandPUT /Contactaccept it. WeGive can set it; Virtuous does not echo it back on reads. - Global search response —
Contact/FindreturnsisArchivedper hit, but this is a search endpoint (one query → small ranked result set), not a bulk corpus walker. - Query request body flag —
includeArchivedis a request-side toggle, not a per-record field.
The one paginated endpoint that fits the rate-limit budget — Query/FullContact with take=1000 — does not carry isArchived on the returned rows. There is no scannable endpoint that exposes per-record archive state.
The closest derivable signal is exclusion: walking the corpus with includeArchived: false (the default), then diffing returned IDs against locally-tracked virtuous_contact_id values, and treating any of ours missing from the result set as “archived.” This is the only path Avenue C leaves open, and it is materially worse than a real signal — see § Why the full-corpus presence diff isn’t a real solution below.
Result: No direct state-shaped read at scale. Only indirect, via expensive corpus diffing.
Avenue D — Reactive per-record probe (current behavior)
WeGive’s current behavior, implemented in app/Integrations/Virtuous.php, is to detect a missing contact reactively on the next outbound push:
When WeGive attempts to update a donor with an existing virtuous_contact_individual_id, it first issues GET /api/ContactIndividual/:id. A 404 is treated as a hard sync failure for that donor (Contact no longer exists in Virtuous), so the customer’s archive intent is honored — WeGive will not silently recreate the contact on the next push.
This signal has structural limits:
- No proactive mirror. The signal only fires when WeGive next tries to push an update for that specific donor. Until then — possibly hours, days, or never — WeGive’s local state still shows the donor as active.
- No timing. WeGive learns “the contact is gone” but not when it was archived. Any downstream behavior depending on archive timestamp cannot be built.
- Not bulk-feasible. Probing every donor individually would consume
N / 60 / 1500minutes of rate budget per org. For a 100k-contact tenant that is ~67 minutes of full-throttle GETs purely for the read, leaving no budget for writes or other syncs. - Ambiguous. A
404can mean three different things — see next section.
Result: Works as a defensive write-side guard, which is exactly what it is in production. Structurally incapable of being a sync mechanism.
The disambiguation problem
A 404 on GET /ContactIndividual/:id — or a contact’s absence from a Query/FullContact walk — could mean any of:
- Contact archived via
PUT /api/Contact/Archive/:contactId. Soft delete; reversible. - Contact deleted entirely. There is no top-level public
DELETE /Contact/:idendpoint, but Virtuous can delete contacts internally and the API surfaces the result as a404. - Individual deleted via the separate
Delete an Individualendpoint, while the parent contact remains. Common for households where one member is removed.
The Virtuous API exposes no field that lets us tell these apart. From WeGive’s perspective they all produce the same observable signal (record unreachable), but they have different semantics on the customer side. Reflecting “archived” specifically — rather than just “gone” — requires a disambiguator the API does not provide.
(Note: contact merges are a separate case Virtuous does expose, via mergedIntoContactId on a successful read. WeGive handles that case — see Contact mapping → Merged contacts.)
Why the full-corpus presence diff isn’t a real solution
The only path Avenue C leaves open: periodically (e.g. nightly) page through POST /Contact/Query/FullContact with includeArchived: false, build a set of returned contact IDs, then mark every locally-tracked donor whose virtuous_contact_id is absent from that set as “archived in Virtuous.”
This is theoretically buildable but materially worse than a real signal:
- Cost. For a 200k-contact org, that is ~200 paginated requests per sweep, repeated per-org, repeated nightly. Tractable, but a heavy floor on rate budget that must be coordinated with all other Virtuous syncs (donations, scheduled gifts, etc.).
- Still ambiguous. A missing ID could be archived, deleted, or never have existed at the current Virtuous tenant at all (e.g., reconnect-to-different-org cases). All three would be marked the same way.
- No timing fidelity. “Currently archived” but not “was archived at T.” Downstream behavior that depends on archive timestamp (suppression windows, last-touch reporting, audit trails) cannot be built on top.
- No clean unarchive symmetry. If a customer unarchives in Virtuous, the contact reappears on the next sweep — but WeGive may have already fired downstream effects (suppressed communications, marked donor inactive, etc.). Reversibility is awkward.
- Races with
contactUpdatewebhooks. Webhook deliveries arrive between sweeps and modify the same donor records the sweep is reading. Precedence and idempotency would need clear rules. - Operational fragility. A failed or partial sweep produces false-positive archive marks. Recovery requires re-running the sweep and reverting false flags, which itself needs an audit trail.
This is not “a switch we can flip.” It is a significant engineering investment for a feature with ambiguous semantics, no archive-time fidelity, and a fragile unarchive path.
What would unblock this
The blocker is the Virtuous API surface, not WeGive engineering choice. Either of the following from Virtuous would make a clean implementation possible:
- A
contactArchive/contactUnarchive(and ideallycontactDelete) webhook event, or - An
isArchivedfield on the paginatedPOST /api/Contact/Query/FullContactresponse, ideally accompanied byarchivedDateTimeUtc.
Without one of those, no implementation we build will be both scalable and semantically correct.
Summary
| Signal | Available? | Why not |
|---|---|---|
| Webhook on archive | No | Not in the Virtuous webhook event set |
| Archive timestamp on contact reads | No | Not in any read schema |
archivedSince query filter | No | Only includeArchived: bool toggle exists |
isArchived on paginated bulk read | No | Only on global search hits, not bulk endpoints |
Per-record 404 probe | Yes, but reactive | Ambiguous (archive vs delete vs individual-delete); not bulk-feasible under rate limit |
| Full-corpus presence diff | Buildable, not advisable | Heavy cost, ambiguous, no timing, fragile unarchive |
The current behavior — reactive 404 detection on push, surfaced as a hard sync failure so the donor is not silently recreated — is the most we can do with the signals Virtuous provides.
Related
- Contact mapping — full push/pull behavior for contacts, including the merged-contact case.
- Virtuous API Documentation — endpoint reference cited above.