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

# Stripe to Virtuous CRM

> A complete integration recipe for syncing donations from Stripe into Virtuous CRM+ — Customer-to-Contact mapping, Charge-to-Gift mapping, refunds, and recurring subscriptions.

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](/crm/workflows/sync-external-donations) 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.

<Note>
  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.
</Note>

## Architecture

```mermaid theme={null}
graph LR
  Stripe["Stripe<br/>(payment events)"]
  StripeWebhook["Stripe Webhook<br/>Receiver"]
  Queue["Outbound Queue<br/>(per-customer)"]
  Submitter["Virtuous Submitter<br/>Worker"]
  Virtuous["Virtuous CRM+"]
  VirtuousWebhook["Virtuous Webhook<br/>Receiver"]
  Records["Sync Records"]

  Stripe -->|charge.succeeded, etc.| StripeWebhook
  StripeWebhook -->|enqueue| Queue
  Queue --> Submitter
  Submitter -->|POST /api/v2/Gift/Transaction| Virtuous
  Virtuous -->|giftCreate| VirtuousWebhook
  VirtuousWebhook --> Records
```

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 field                                   | Source from Stripe                                                        |
| ------------------------------------------------ | ------------------------------------------------------------------------- |
| `referenceSource`                                | The literal string `"Stripe"` — your platform identifier.                 |
| `referenceId`                                    | The Stripe Customer ID (typically prefixed `cus_`).                       |
| `firstName` / `lastName`                         | Parsed 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`, `country` | Stripe Customer `address` or Charge `billing_details.address`.            |

### Stripe Charge / PaymentIntent → Virtuous Gift

| Virtuous field                        | Source from Stripe                                                                             |
| ------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `transactionSource`                   | The literal string `"Stripe"`.                                                                 |
| `transactionId`                       | The Stripe Charge ID (`ch_*`) or PaymentIntent ID (`pi_*`) — pick one and use it consistently. |
| `giftDate`                            | Charge `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.        |
| `amount`                              | Charge `amount` divided by 100 (Stripe stores cents; Virtuous expects dollars).                |
| `currencyCode`                        | Charge `currency`, uppercased.                                                                 |
| `giftDesignations[].projectCode`      | Project code resolved from Charge `metadata` (see below).                                      |
| `giftDesignations[].amountDesignated` | Matches `amount` for single-designation gifts.                                                 |

<Tip>
  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.
</Tip>

### 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 JavaScript theme={null}
// 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 event               | What it represents                                             | Virtuous action                                                           |
| -------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `payment_intent.succeeded` | A payment completed successfully.                              | Submit `POST /api/v2/Gift/Transaction`.                                   |
| `charge.refunded`          | A previously-recorded payment was fully or partially refunded. | Submit a reversing transaction via `POST /api/Gift/ReversingTransaction`. |
| `charge.dispute.created`   | A donor disputed a charge.                                     | Surface to the customer for review; optionally tag the Gift.              |

For subscription-based recurring donations, add:

| Stripe event                    | What it represents                            | Virtuous action                                                                                                                                     |
| ------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customer.subscription.created` | A new recurring donation schedule began.      | Submit `POST /api/RecurringGift` to create the schedule in Virtuous. See [Sync Recurring Donor Updates](/crm/recipes/sync-recurring-donor-updates). |
| `invoice.paid`                  | A subscription's recurring payment succeeded. | Submit `POST /api/v2/Gift/Transaction` with `recurringGiftTransactionId` linking to the schedule.                                                   |
| `customer.subscription.deleted` | A 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 JavaScript theme={null}
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](/crm/workflows/sync-external-donations#part-2-the-submitter-worker) 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 JavaScript theme={null}
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 JavaScript theme={null}
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](/crm/concepts/gifts#reversals-and-refunds) for the reasoning behind the reversing-transaction pattern.

<Note>
  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](/crm/workflows/handle-duplicate-records#the-reversing-transaction-pattern).
</Note>

***

## 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 JavaScript theme={null}
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
  }
}
```

<Note>
  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](/crm/concepts/statuses-and-lifecycle-states) page.
</Note>

### When a subscription payment succeeds

```javascript JavaScript theme={null}
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 JavaScript theme={null}
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](/crm/recipes/sync-recurring-donor-updates) for the full RecurringGift lifecycle treatment.

***

## Reconciliation specific to Stripe

The general reconciliation patterns from [Reconcile Failed Syncs](/crm/workflows/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 JavaScript theme={null}
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](/crm/webhooks/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

<CardGroup cols={2}>
  <Card title="Sync Recurring Donor Updates" icon="arrows-rotate" href="/crm/recipes/sync-recurring-donor-updates">
    The deeper treatment of RecurringGift lifecycle management — paused subscriptions, failed payments, and donor-driven amount changes.
  </Card>

  <Card title="Import Historical Gifts" icon="boxes-stacked" href="/crm/recipes/import-historical-gifts">
    Backfill historical Stripe charges into Virtuous as part of the initial integration setup.
  </Card>

  <Card title="Sync External Donations into Virtuous" icon="arrow-right-arrow-left" href="/crm/workflows/sync-external-donations">
    The general architecture this Stripe recipe instantiates.
  </Card>

  <Card title="Reconcile Failed Syncs" icon="arrows-rotate" href="/crm/workflows/reconcile-failed-syncs">
    Handle the inevitable Stripe-to-Virtuous sync gaps that emerge over time.
  </Card>
</CardGroup>
