Skip to content

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 isArchived field 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, contactUpdate
giftCreate, giftUpdate, giftDelete
projectCreate, projectUpdate, projectDelete
formSubmission
contactNoteCreate, contactNoteUpdate, contactNoteDelete
eventCreate, eventUpdate, eventDelete

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

EndpointisArchived in response?
GET /api/Contact/:contactIdNo
GET /api/Contact/:referenceSource/:referenceIdNo
GET /api/ContactIndividual/:idNo
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 payloadsPOST /Contact and PUT /Contact accept it. WeGive can set it; Virtuous does not echo it back on reads.
  • Global search responseContact/Find returns isArchived per hit, but this is a search endpoint (one query → small ranked result set), not a bulk corpus walker.
  • Query request body flagincludeArchived is 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 / 1500 minutes 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 404 can 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:

  1. Contact archived via PUT /api/Contact/Archive/:contactId. Soft delete; reversible.
  2. Contact deleted entirely. There is no top-level public DELETE /Contact/:id endpoint, but Virtuous can delete contacts internally and the API surfaces the result as a 404.
  3. Individual deleted via the separate Delete an Individual endpoint, 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:

  1. 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.).
  2. 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.
  3. 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.
  4. 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.
  5. Races with contactUpdate webhooks. Webhook deliveries arrive between sweeps and modify the same donor records the sweep is reading. Precedence and idempotency would need clear rules.
  6. 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 ideally contactDelete) webhook event, or
  • An isArchived field on the paginated POST /api/Contact/Query/FullContact response, ideally accompanied by archivedDateTimeUtc.

Without one of those, no implementation we build will be both scalable and semantically correct.

Summary

SignalAvailable?Why not
Webhook on archiveNoNot in the Virtuous webhook event set
Archive timestamp on contact readsNoNot in any read schema
archivedSince query filterNoOnly includeArchived: bool toggle exists
isArchived on paginated bulk readNoOnly on global search hits, not bulk endpoints
Per-record 404 probeYes, but reactiveAmbiguous (archive vs delete vs individual-delete); not bulk-feasible under rate limit
Full-corpus presence diffBuildable, not advisableHeavy 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.