Skip to main content
This recipe walks through building a Stripe-to-Virtuous integration: receiving payment events from Stripe, identifying or creating the corresponding Virtuous Contact, recording the donation as a Gift, and handling the lifecycle events that follow (refunds, subscription renewals, cancellations). The architecture instantiates the patterns from Sync External Donations into Virtuous for the Stripe-specific case. Read that page first if you have not — this recipe assumes familiarity with the outbound queue, submitter worker, webhook receiver, and reconciliation poller pattern.
This recipe is an architectural pattern, not a copy-paste implementation. The specifics of Stripe’s API (event names, ID prefixes, field shapes) evolve over time — confirm against Stripe’s current documentation before using any specific field name in production. The Virtuous-side mapping is the part of the recipe that’s most durable.

Architecture

Two webhook receivers — one for inbound Stripe events, one for outbound confirmations from Virtuous — with an outbound queue between them. The queue decouples Stripe’s delivery rate from Virtuous’s processing rate and provides durable storage if any component is temporarily down.

Field mapping

The core of the integration is mapping Stripe’s data model to Virtuous’s. Stripe organizes data around Customers (donors) and Charges or PaymentIntents (donations); Virtuous organizes around Contacts and Gifts. The mapping:

Stripe Customer → Virtuous Contact

Virtuous fieldSource from Stripe
referenceSourceThe literal string "Stripe" — your platform identifier.
referenceIdThe Stripe Customer ID (typically prefixed cus_).
firstName / lastNameParsed from Stripe Customer name field, or from Charge billing details.
email (with emailType: "Home Email")Stripe Customer email or Charge receipt_email.
phone (with phoneType: "Mobile Phone")Stripe Customer phone.
address1, city, state, postal, countryStripe Customer address or Charge billing_details.address.

Stripe Charge / PaymentIntent → Virtuous Gift

Virtuous fieldSource from Stripe
transactionSourceThe literal string "Stripe".
transactionIdThe Stripe Charge ID (ch_*) or PaymentIntent ID (pi_*) — pick one and use it consistently.
giftDateCharge created timestamp, converted to a date in the customer’s organization timezone.
giftType"Cash" for credit-card or ACH charges, "EFT" for bank transfers if you distinguish.
amountCharge amount divided by 100 (Stripe stores cents; Virtuous expects dollars).
currencyCodeCharge currency, uppercased.
giftDesignations[].projectCodeProject code resolved from Charge metadata (see below).
giftDesignations[].amountDesignatedMatches amount for single-designation gifts.
Pick either the Charge ID (ch_*) or the PaymentIntent ID (pi_*) as your transactionId and use it consistently across your integration’s lifetime. Mixing the two will produce duplicate Gifts in Virtuous when the same payment appears under both IDs. PaymentIntent is the modern Stripe primitive; new integrations should default to it.

Capturing the project designation

A typical donor flow on a Stripe-powered donation form includes a “Designate to” field that picks a Virtuous Project. Pass the Project code in Stripe’s metadata on the Charge or PaymentIntent:
JavaScript
// On the donation form, when creating the PaymentIntent:
const paymentIntent = await stripe.paymentIntents.create({
  amount: 50000, // $500.00 in cents
  currency: 'usd',
  customer: stripeCustomerId,
  metadata: {
    virtuous_project_code: 'CLEAN-WATER',
    virtuous_segment_code: 'YE-2024',
    // ...any other Virtuous-side context to carry through
  },
});
When your webhook receiver processes the charge.succeeded (or payment_intent.succeeded) event, it reads metadata.virtuous_project_code and uses it in the Virtuous Gift Transaction’s designation.

Stripe events to subscribe to

Subscribe your Stripe webhook endpoint to the events your integration acts on. A minimal set for a one-time-gift integration:
Stripe eventWhat it representsVirtuous action
payment_intent.succeededA payment completed successfully.Submit POST /api/v2/Gift/Transaction.
charge.refundedA previously-recorded payment was fully or partially refunded.Submit a reversing transaction via POST /api/Gift/ReversingTransaction.
charge.dispute.createdA donor disputed a charge.Surface to the customer for review; optionally tag the Gift.
For subscription-based recurring donations, add:
Stripe eventWhat it representsVirtuous action
customer.subscription.createdA new recurring donation schedule began.Submit POST /api/RecurringGift to create the schedule in Virtuous. See Sync Recurring Donor Updates.
invoice.paidA subscription’s recurring payment succeeded.Submit POST /api/v2/Gift/Transaction with recurringGiftTransactionId linking to the schedule.
customer.subscription.deletedA recurring donation was cancelled.Cancel the corresponding RecurringGift via PUT /api/RecurringGift/Cancel/{recurringGiftId}.

The Stripe webhook receiver

The receiver verifies Stripe’s signature, enqueues the event for processing, and acknowledges immediately:
JavaScript
import express from 'express';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

app.post(
  '/stripe/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    // 1. Verify Stripe signature
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers['stripe-signature'],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(401).send('Invalid Stripe signature');
    }

    // 2. Enqueue for processing — durable queue, not in-memory
    await db.virtuous_donation_queue.insert({
      platform_donation_id: event.id,  // The Stripe event ID is your stable idempotency anchor
      customer_id: deriveCustomerId(event),
      stripe_event_type: event.type,
      stripe_event_payload: event,
      status: 'pending',
    }, { onConflict: 'do_nothing' });  // Stripe retries also produce duplicate event IDs

    // 3. Acknowledge immediately
    res.status(200).send('OK');
  }
);
Three patterns to note:
  • Stripe’s event ID is your idempotency anchor on the Stripe side. Use it as the primary key in your queue with ON CONFLICT DO NOTHING to handle Stripe’s webhook retries cleanly.
  • The Stripe event payload is stored verbatim. Your worker reads from the queue and constructs the Virtuous submission from the stored payload — meaning replay and reprocessing are possible without re-fetching from Stripe.
  • Acknowledge before processing. The Virtuous submission happens out-of-band in the worker, not synchronously in the webhook handler.
See Sync External Donations into Virtuous for the worker pattern that drains this queue.

Processing payment_intent.succeeded

The most common event — a donor made a one-time donation. The worker constructs and submits the Gift Transaction:
JavaScript
async function processPaymentIntentSucceeded(stripeEvent, customerId, token) {
  const intent = stripeEvent.data.object;
  const stripeCustomer = await stripe.customers.retrieve(intent.customer);

  // Determine the project designation from metadata
  const projectCode = intent.metadata.virtuous_project_code;
  if (!projectCode) {
    // No project specified — surface to customer for manual designation,
    // or fall back to a default project configured on your side.
    throw new Error('No virtuous_project_code in payment metadata');
  }

  const response = await fetch(
    'https://api.virtuoussoftware.com/api/v2/Gift/Transaction',
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        transactionSource: 'Stripe',
        transactionId: intent.id,  // pi_xxx — stable across retries
        contact: {
          referenceId: stripeCustomer.id,  // cus_xxx
          type: 'Household',  // Adjust if your form distinguishes individuals from organizations
          firstname: extractFirstName(stripeCustomer),
          lastname: extractLastName(stripeCustomer),
          emailType: 'Home Email',
          email: stripeCustomer.email,
          phoneType: stripeCustomer.phone ? 'Mobile Phone' : undefined,
          phone: stripeCustomer.phone,
          address: stripeCustomer.address ? {
            address1: stripeCustomer.address.line1,
            address2: stripeCustomer.address.line2,
            city: stripeCustomer.address.city,
            state: stripeCustomer.address.state,
            postal: stripeCustomer.address.postal_code,
            country: stripeCustomer.address.country,
          } : undefined,
        },
        giftDate: new Date(intent.created * 1000).toISOString().slice(0, 10),
        giftType: 'Cash',
        amount: (intent.amount / 100).toFixed(2),
        currencyCode: intent.currency.toUpperCase(),
        batch: intent.metadata.virtuous_batch ?? 'Stripe',
        giftDesignations: [
          {
            projectCode,
            amountDesignated: (intent.amount / 100).toFixed(2),
          },
        ],
      }),
    }
  );

  if (!response.ok) {
    throw new Error(`Virtuous submission failed: ${response.status}`);
  }
}
Two patterns worth calling out:
  • The Stripe Customer’s id becomes Virtuous’s referenceId. This is the bridge between the two platforms’ donor identifiers. Virtuous’s matching algorithm uses referenceSource: "Stripe" + referenceId: cus_xxx as its highest-priority match signal.
  • amount conversion is critical. Stripe stores money as integer cents; Virtuous expects dollar decimals. Off-by-100 errors are the most common partner bug — every gift recorded at 100x its intended value. Convert defensively and ideally unit-test the conversion.

Processing charge.refunded

When a donor’s payment is refunded — by the customer’s staff in the Stripe dashboard, by a chargeback resolution, or by your platform’s refund logic — record a reversing transaction in Virtuous to keep the accounting accurate:
JavaScript
async function processChargeRefunded(stripeEvent, customerId, token) {
  const charge = stripeEvent.data.object;

  // Find the original Gift in Virtuous by transaction reference
  const originalResponse = await fetch(
    `https://api.virtuoussoftware.com/api/Gift/Stripe/${encodeURIComponent(charge.payment_intent || charge.id)}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );

  if (!originalResponse.ok) {
    if (originalResponse.status === 404) {
      // The original Gift doesn't exist yet — Stripe's refund event arrived
      // before our giftCreate webhook from Virtuous. Defer and retry later.
      throw new Error('Original Gift not yet in Virtuous — will retry');
    }
    throw new Error(`Lookup failed: ${originalResponse.status}`);
  }

  const originalGift = await originalResponse.json();

  // Submit a reversing transaction
  await fetch(
    'https://api.virtuoussoftware.com/api/Gift/ReversingTransaction',
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        reversedGiftId: originalGift.id,
        giftDate: new Date().toISOString().slice(0, 10),
        notes: `Stripe refund processed ${new Date().toISOString()} — Refund ${charge.refunds.data[0]?.id}`,
      }),
    }
  );
}
See Donations / Gifts — Reversals and refunds for the reasoning behind the reversing-transaction pattern.
The exact field set for POST /api/Gift/ReversingTransaction is not fully enumerated in the spec. The fields shown above (reversedGiftId, giftDate, notes) reflect the conceptual pattern; confirm the authoritative request shape before going to production. See Handle Duplicate Records.

Subscription-based recurring donations

For donors who set up a recurring donation through a Stripe Subscription, your integration tracks two related concepts in Virtuous:
  • A RecurringGift representing the schedule itself (the donor’s commitment to give $50/month forever).
  • One Gift per successful payment — generated automatically by Stripe each billing cycle, recorded in Virtuous as a Gift linked to the schedule.

When the subscription is created

JavaScript
async function processSubscriptionCreated(stripeEvent, customerId, token) {
  const subscription = stripeEvent.data.object;
  const stripeCustomer = await stripe.customers.retrieve(subscription.customer);

  // Determine the Virtuous Contact ID — either it exists, or it will after
  // the first invoice.paid event creates the donor. For now, store the
  // Stripe customer ID; the RecurringGift creation can defer until the
  // Contact is known.

  // For each recurring item, create a Virtuous RecurringGift
  for (const item of subscription.items.data) {
    const amount = item.price.unit_amount * item.quantity;

    await fetch(
      'https://api.virtuoussoftware.com/api/RecurringGift',
      {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          transactionSource: 'Stripe',
          transactionId: subscription.id,  // sub_xxx
          contactId: await resolveContactIdFromStripeCustomer(stripeCustomer, token),
          startDate: new Date(subscription.start_date * 1000).toISOString().slice(0, 10),
          amount: (amount / 100).toFixed(2),
          frequency: mapStripeIntervalToVirtuous(item.price.recurring.interval),
          automatedPayments: true,
          trackPayments: true,
          designations: [
            {
              projectCode: subscription.metadata.virtuous_project_code,
              amountDesignated: (amount / 100).toFixed(2),
            },
          ],
        }),
      }
    );
  }
}

function mapStripeIntervalToVirtuous(stripeInterval) {
  // The Virtuous frequency enum is not fully documented — confirm valid values
  switch (stripeInterval) {
    case 'month':  return 'Monthly';
    case 'year':   return 'Annually';
    case 'week':   return 'Weekly';
    case 'day':    return 'Daily';
    default:       return 'Monthly';  // Safe default
  }
}
The valid frequency values for POST /api/RecurringGift are not enumerated in the CRM+ spec. The mapping above is a typical pattern but may need adjustment.⚠️ Human input required: Confirm the canonical frequency enum values for RecurringGift, and update both this recipe and the Statuses and Lifecycle States page.

When a subscription payment succeeds

JavaScript
async function processInvoicePaid(stripeEvent, customerId, token) {
  const invoice = stripeEvent.data.object;

  // Submit a Gift Transaction linked to the RecurringGift
  await fetch(
    'https://api.virtuoussoftware.com/api/v2/Gift/Transaction',
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        transactionSource: 'Stripe',
        transactionId: invoice.payment_intent,  // The specific payment's PaymentIntent ID
        recurringGiftTransactionId: invoice.subscription,  // Links to the schedule
        contact: { referenceId: invoice.customer },
        giftDate: new Date(invoice.created * 1000).toISOString().slice(0, 10),
        giftType: 'Cash',
        amount: (invoice.amount_paid / 100).toFixed(2),
        currencyCode: invoice.currency.toUpperCase(),
        batch: 'Stripe-Recurring',
        giftDesignations: [
          {
            projectCode: invoice.subscription_details?.metadata?.virtuous_project_code,
            amountDesignated: (invoice.amount_paid / 100).toFixed(2),
          },
        ],
      }),
    }
  );
}
The recurringGiftTransactionId field on the Gift Transaction links the new Gift to the existing RecurringGift schedule. Virtuous’s matching algorithm uses this to associate the payment with the right donor recurring history.

When a subscription is cancelled

JavaScript
async function processSubscriptionDeleted(stripeEvent, customerId, token) {
  const subscription = stripeEvent.data.object;

  // Find the Virtuous RecurringGift by transaction reference
  // (There is no direct lookup-by-reference endpoint for RecurringGift in the spec;
  //  query for it by transactionSource/transactionId via POST /api/RecurringGift/Query
  //  or maintain the mapping in your own database from the create-time response.)
  const virtuousRecurringGiftId = await lookupRecurringGiftId(subscription.id);

  await fetch(
    `https://api.virtuoussoftware.com/api/RecurringGift/Cancel/${virtuousRecurringGiftId}`,
    {
      method: 'PUT',
      headers: { Authorization: `Bearer ${token}` },
    }
  );
}
See Sync Recurring Donor Updates for the full RecurringGift lifecycle treatment.

Reconciliation specific to Stripe

The general reconciliation patterns from Reconcile Failed Syncs apply. A few Stripe-specific reconciliation queries:

Stripe daily payout reconciliation

Stripe’s daily payouts list every charge included in that day’s deposit. For accounting reconciliation, compare Stripe’s payout report with Virtuous’s gifts:
JavaScript
async function reconcileStripePayouts(payoutDate, token) {
  // Fetch all charges in the payout from Stripe
  const charges = await stripe.charges.list({
    created: { gte: payoutDate.start, lte: payoutDate.end },
    limit: 100,
  });

  // Fetch all Gifts in the same date range from Virtuous
  const virtuousGifts = await queryVirtuousGiftsByDate(payoutDate, token);

  // For each Stripe charge, confirm a matching Gift exists in Virtuous
  for (const charge of charges.data) {
    const expectedTransactionId = charge.payment_intent || charge.id;
    const match = virtuousGifts.find(
      (g) => g.transactionSource === 'Stripe' && g.transactionId === expectedTransactionId
    );

    if (!match) {
      console.warn(`Stripe charge missing in Virtuous: ${expectedTransactionId}`);
      // Trigger resubmission or surface to customer
    }
  }
}
This is the most common partner-side reconciliation request — accountants want monthly confirmation that every Stripe deposit is reflected in Virtuous.

Security checklist

Before deploying a Stripe-to-Virtuous integration to production, confirm:
  • Stripe webhook signature verification runs on every incoming request.
  • Virtuous webhook signature verification runs on every incoming request. See Signature Verification.
  • The Stripe webhook secret is loaded from a secrets manager — never hardcoded.
  • The Virtuous API token is loaded from a secrets manager, scoped per customer.
  • Stripe Customer IDs are stored in your database alongside Virtuous Contact IDs — both are needed for reconciliation.
  • transactionId on Virtuous submissions is always the stable Stripe identifier (PaymentIntent ID is recommended).
  • The amount-conversion (cents → dollars) is unit-tested.
  • Refunds use POST /api/Gift/ReversingTransaction, not DELETE /api/Gift/{giftId}.
  • Subscription cancellations use PUT /api/RecurringGift/Cancel/{id}, not deletion.
  • Reconciliation queries run on a schedule and produce reports the customer’s accounting team can review.

Where to go next

Sync Recurring Donor Updates

The deeper treatment of RecurringGift lifecycle management — paused subscriptions, failed payments, and donor-driven amount changes.

Import Historical Gifts

Backfill historical Stripe charges into Virtuous as part of the initial integration setup.

Sync External Donations into Virtuous

The general architecture this Stripe recipe instantiates.

Reconcile Failed Syncs

Handle the inevitable Stripe-to-Virtuous sync gaps that emerge over time.
Last modified on May 21, 2026