> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wegive.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Integration Nuances

> Platform-specific behaviors, limitations, and edge cases of the WeGive Virtuous integration

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

| 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 /Contact` and `PUT /Contact` accept it. WeGive can set it; Virtuous does not echo it back on reads.
* **Global search response** — `Contact/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 flag** — `includeArchived` 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](#why-the-full-corpus-presence-diff-isnt-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](/external/onboarding/virtuous/data-mapping/contact#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

| 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](/external/onboarding/virtuous/data-mapping/contact) — full push/pull behavior for contacts, including the merged-contact case.
* [Virtuous API Documentation](https://developers.virtuoussoftware.com/) — endpoint reference cited above.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.