The architecture
A robust donation sync has four moving parts: The four parts:- Outbound queue — durable storage of donations awaiting submission. New donations from your platform enter the queue; submitted donations leave it.
- Submitter worker — drains the queue, calls
POST /api/v2/Gift/Transaction, and updates each record’s state to “submitted” on success. - Webhook receiver — handles incoming
giftCreateevents from Virtuous, matches them to submitted donations, and records the resulting Virtuous Gift ID. - Reconciliation poller — a periodic safety net that catches donations stuck in any intermediate state.
Part 1: capture donations into the outbound queue
Whenever a donation occurs on your platform — a successful Stripe charge, a completed Eventbrite registration, a confirmed event ticket sale — write it to a durable outbound queue or table. Do not call Virtuous directly from your platform’s hot path.platform_donation_idis the primary key. This guarantees that a donation captured twice (a Stripe webhook retry, your platform’s internal retry) is deduplicated at insertion time. UseINSERT ... ON CONFLICT DO NOTHINGfor idempotent capture.- The full payload is stored as JSONB. This lets the submitter resubmit without reconstructing the request and lets you replay any donation through the pipeline later.
- State is explicit. The lifecycle (
pending → submitted → confirmed, orfailed) makes monitoring and reconciliation straightforward.
Part 2: the submitter worker
A worker drains the queue, sendingpending donations to Virtuous. The worker is the single component that calls POST /api/v2/Gift/Transaction.
JavaScript
- Stable
transactionSourceandtransactionId. The payload stored in the queue contains them — they don’t change between retries. This is the foundation of the idempotency guarantee with Virtuous. - State transitions on success only. A donation only leaves
pendingwhen Virtuous confirms acceptance (2xxresponse). Transient failures leave the record inpendingfor the next run to retry. - 4xx vs. 5xx differentiation. 4xx is a client-side problem that won’t resolve on retry — mark
failedand surface to humans. 5xx is server-side and likely transient — keep retrying.
Cadence and concurrency
Run the submitter on a schedule (every few minutes is typical) or trigger it on queue inserts. For multi-tenant integrations, run a worker per customer to isolate rate-limit budgets — see Rate Limits. Avoid concurrent submissions of the same customer’s queue from multiple worker instances. Use a queue lock or “FOR UPDATE SKIP LOCKED” SQL pattern to ensure at most one worker drains a customer’s queue at a time.Part 3: the webhook receiver
When Virtuous’s nightly batch processes a submitted Transaction, it fires agiftCreate webhook to your endpoint. The receiver matches the event to the pending submission and transitions the record to confirmed.
JavaScript
confirmed status — a duplicate webhook delivery (which will happen, per Idempotency and Safe Reprocessing) is a no-op. See Webhooks Overview for the surrounding receiver pattern including signature verification.
Handling Contact webhooks
If your integration submits donations from new donors, you may also receivecontactCreate events when the nightly batch creates the donor’s Contact record. These don’t need special handling for the donation flow — the donor’s Contact ID is also embedded in the giftCreate event’s payload (contactId). Subscribe to contactCreate only if your integration also tracks Contacts independently.
Part 4: the reconciliation poller
Webhooks are the primary outcome-detection mechanism, but a small fraction of donations will not produce a webhook: events lost during an outage, deliveries that exhausted retries, or Transactions that landed in the needs-update bucket because matching failed. A periodic reconciliation poll catches these.JavaScript
Initial historical load
Most partner integrations need to import some historical donation data on the first connection — either to bring a new customer’s existing Stripe history into a new Virtuous instance, or to backfill data that pre-dates the integration. The same architecture handles both ongoing sync and historical load:Capture all historical donations into the queue
Iterate your platform’s historical donation data and insert each donation into
virtuous_donation_queue with status pending. For very large backfills (tens of thousands of records), insert in batches with ON CONFLICT DO NOTHING to handle re-runs.Let the normal submitter drain the queue
The submitter doesn’t distinguish historical from current — it processes all
pending records. Throttle the worker’s cadence if the backfill would exceed the rate limit (1,500 requests/hour per organization — see Rate Limits).Let the webhook receiver confirm outcomes
Virtuous fires
giftCreate for every Transaction the batch processes, including backfilled ones. Your normal receiver path handles them.Multi-tenant considerations
Partner integrations serving multiple nonprofit customers need a few additional patterns:Per-customer credentials
Each nonprofit customer has their own Virtuous API token. The submitter and reconciliation poller must load the correct token for the customer they are processing. Use a secrets manager keyed by yourcustomer_id.
Per-customer rate-limit isolation
The 1,500-per-hour rate limit applies per Virtuous organization (one customer = one bucket). A burst in one customer’s sync does not affect others. Run a per-customer worker rather than a global one — this prevents one busy customer from monopolizing a global queue at the expense of others.Per-customer webhook subscriptions
Each customer’s Virtuous organization needs its own webhook subscription pointing at your endpoint. Create the subscription during customer onboarding viaPOST /api/Webhook against that customer’s organization. Verify it remains active periodically — see Webhooks Overview.
Per-customer reconciliation
Run reconciliation per customer, not globally. Thecustomer_id column in your queue scopes the work — the reconciliation poller for customer A doesn’t touch customer B’s records.
Monitoring
Track these metrics on the sync pipeline:| Metric | Why |
|---|---|
| Queue depth (pending) | A growing depth indicates the submitter can’t keep up — likely rate-limit or API issue. |
| Time from pending → confirmed | The normal value is roughly the nightly batch window plus webhook delivery time. A growing value indicates batch delays or webhook outages. |
| Count in failed status | Each failed record needs human investigation. Alert if this grows. |
| Count in needs_review status | Records stuck because Virtuous’s matching couldn’t auto-resolve them. Alert at a non-zero threshold. |
| giftCreate webhook arrival rate | Should track the submission rate at roughly a nightly-batch delay. A divergence indicates webhook delivery issues. |
End-to-end checklist
Before considering a donation sync integration production-ready, confirm:- Captures donations into a durable queue keyed by your platform’s stable identifier.
- Submitter uses stable
transactionSourceandtransactionIdacross retries — never regenerated. - Submitter differentiates retryable (5xx, network errors) from permanent (4xx) failures.
- Webhook receiver verifies signatures (see Signature Verification).
- Webhook receiver is idempotent — a duplicate delivery does not double-count anything.
- Reconciliation poller runs on a schedule and updates status for previously-missed deliveries.
- Multi-tenant: per-customer credentials, queues, and webhook subscriptions.
- Monitoring covers queue depth, failure rate, and webhook arrival rate.
- Failure states (
failed,needs_review) surface to humans for investigation.
Where to go next
Reconcile Failed Syncs
The deeper treatment of reconciliation — handling needs-update, manual resolution, and recovery patterns.
Handle Duplicate Records
What to do when matching fails and produces duplicate Contacts or Gifts.
Build a Two-Way Sync
Extend this one-way donation sync into a continuous two-way sync of Contacts and Gifts.
Stripe to Virtuous CRM
A complete integration recipe that instantiates this architecture for Stripe-sourced donations.