# Stripe

URL: https://supapulse.app/docs/guides/stripe

Connect Stripe to Supapulse with a per-site webhook. Purchases, subscriptions, renewals, refunds and cancellations are credited to the visits that brought each customer.

Supapulse has a built-in Stripe webhook. You add one endpoint in Stripe and Supapulse turns Stripe's events into revenue, MRR and refunds, with no handler code on your server. You identify the customer in the browser so Supapulse can credit each payment to the visit that first brought them.

## Before you start [#before-you-start]

- Supapulse is installed on the site where visitors sign up ([quick start](https://supapulse.app/docs/quickstart)).
- You can add webhook endpoints in the Stripe dashboard.

## 1. Identify the customer in the browser [#1-identify-the-customer-in-the-browser]

After login or signup, tell Supapulse who the visitor is:

```ts
supapulse.identify(user.id); // window.supapulse with the script tag
```

In React, call `useIdentify()` from an effect. See [Hooks](https://supapulse.app/docs/react/hooks#useidentify). Use the same ID as `supapulse_customer_id` in step 3.

## 2. Add the endpoint in Stripe [#2-add-the-endpoint-in-stripe]

1. In Supapulse, open the site's settings and find the Revenue section. Copy the endpoint URL. It has the form `https://supapulse.app/api/webhooks/stripe/{publicToken}`.
2. In the Stripe dashboard, open Developers, then Webhooks, and add an endpoint with that URL.
3. Subscribe it to these events and no others:
   - `checkout.session.completed`
   - `invoice.paid`
   - `charge.refunded`
   - `customer.subscription.updated`
   - `customer.subscription.deleted`
4. Copy the endpoint's signing secret (`whsec_…`) and paste it into the Revenue section of the site's settings.

Until the secret is saved the endpoint answers `404`. The settings show when the last event arrived, so you can check the connection. Use a separate Supapulse site for Stripe test mode, so test payments stay out of your real numbers.

## 3. Pass your user ID in metadata [#3-pass-your-user-id-in-metadata]

Set metadata when you create the checkout. These keys are all optional, but they make attribution stronger:

| Metadata key | Value |
| --- | --- |
| `supapulse_customer_id` | Your user ID, the one you pass to `identify()`. |
| `supapulse_visitor_fingerprint` | `await supapulse.getVisitorFingerprint()`. `supapulse_fingerprint` also works. |
| `supapulse_session_id` | `supapulse.getSessionId()`. |

```ts title="api/checkout.ts"
import Stripe from 'stripe';

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

export async function createCheckout(
  priceId: string,
  user: { id: string },
  visitor: { fingerprint?: string; sessionId?: string },
  mode: 'payment' | 'subscription',
) {
  const metadata = {
    supapulse_customer_id: user.id,
    supapulse_visitor_fingerprint: visitor.fingerprint ?? '',
    supapulse_session_id: visitor.sessionId ?? '',
  };

  return stripe.checkout.sessions.create({
    mode,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: 'https://acme.com/thanks',
    cancel_url: 'https://acme.com/pricing',
    metadata,
    // Subscriptions: copy it onto the subscription so every invoice carries it.
    ...(mode === 'subscription' && { subscription_data: { metadata } }),
  });
}
```

The browser reads the values when checkout starts and sends them with the checkout request:

```ts
const response = await fetch('/api/checkout', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    priceId,
    fingerprint: await supapulse.getVisitorFingerprint(), // string | null
    sessionId: supapulse.getSessionId(),
  }),
});
window.location.href = (await response.json()).url;
```

Empty values are ignored. Without any metadata Supapulse still works, using the customer's Stripe ID and whatever `identify()` linked.

### Payment Links [#payment-links]

Payment Links can't take metadata, but they do pass `client_reference_id` from the link's URL to the checkout. Set it to `supapulse_` plus the session ID when the visitor clicks, and Supapulse attributes the purchase to that visit:

```js
document.addEventListener('click', (event) => {
  const link = event.target.closest('a[href*="buy.stripe.com"]');
  const sessionId = window.supapulse?.getSessionId();
  if (!link || !sessionId) return;

  const url = new URL(link.href);
  url.searchParams.set('client_reference_id', `supapulse_${sessionId}`);
  link.href = url.toString();
});
```

This works for guest checkouts too: the customer is keyed on the checkout email, and the purchase is attributed even when the link creates an invoice. Other `client_reference_id` values are left alone.

## What Supapulse records [#what-supapulse-records]

| Stripe event | Becomes |
| --- | --- |
| `checkout.session.completed`, payment mode, paid | A `purchase` with the session's total. |
| `checkout.session.completed`, subscription mode | No revenue. It links the Stripe customer to the visit from the metadata. |
| `invoice.paid` with an amount above zero | `subscription` for the first invoice, `renewal` for later cycles, `upgrade` for plan changes, otherwise `purchase`. The first invoice sets MRR. |
| `charge.refunded` | A `refund` for the newly refunded amount, stored as negative. |
| `customer.subscription.updated` | An `upgrade` or `downgrade` when the monthly price changes, and a `subscription` when a trial turns into a paid plan. |
| `customer.subscription.deleted` | A `churn` that removes the subscription's MRR. Subscriptions that never paid are skipped. |

Other events are ignored. Amounts are converted from Stripe's minor units, including zero-decimal currencies like JPY. MRR normalizes weekly, monthly and yearly prices to a month. Tiered and metered prices count as `0` MRR.

### The customer key [#the-customer-key]

The customer in Supapulse is the Stripe customer ID (`cus_…`). If a payment has no Stripe customer, Supapulse uses `supapulse_customer_id` from the metadata, then the checkout email. Events with none of these are skipped.

### Link Stripe customers to your users [#link-stripe-customers-to-your-users]

`identify(user.id)` records your user ID, and Stripe's events use `cus_…`. When an event carries both `supapulse_customer_id` in its metadata and a Stripe customer ID, Supapulse links them and, when the visit isn't found directly, copies the attribution already stored for your user ID to the Stripe customer. That is why step 3 matters for subscriptions.

### Attribution [#attribution]

Supapulse tries the visitor fingerprint, then the session ID, then the customer's stored touch. Renewals take the touch saved on the first payment. See [Attribution](https://supapulse.app/docs/revenue/attribution).

### Idempotency [#idempotency]

Each revenue row is keyed on the site, a transaction ID taken from Stripe and the event type, so Stripe's retries don't double count. Supapulse verifies the signature with a five-minute tolerance and answers `400` for a bad one.

## Plans [#plans]

If the site's team has no active plan or trial, the endpoint answers `200` with `{ "ignored": "plan_inactive" }` and stores nothing. Revenue isn't blocked by the monthly event limit.

## Testing [#testing]

```bash
stripe listen --forward-to https://supapulse.app/api/webhooks/stripe/{publicToken}
```

`stripe listen` prints its own signing secret. Paste that one into the site's settings while you test, then switch back to the dashboard endpoint's secret.

For an end-to-end test, run a real test-mode checkout from your site. Events from `stripe trigger` have no metadata or customer link, so they appear in totals without attribution.

## Manual webhook handler [#manual-webhook-handler]

Prefer to send revenue from your own server, or need fields the built-in webhook doesn't set? Skip steps 2 and 3, and post `revenue` events yourself. Don't do both for the same payments, or each payment is counted by both paths.

```ts title="api/webhooks/stripe.ts"
import Stripe from 'stripe';
import { Revenue } from '@supapulse/revenue';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const revenue = new Revenue({ apiToken: process.env.SUPAPULSE_SECRET_TOKEN! });

export async function POST(request: Request) {
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      await request.text(),
      request.headers.get('stripe-signature')!,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  if (event.type === 'invoice.paid') {
    const invoice = event.data.object;
    if (invoice.amount_paid > 0) {
      await revenue.track({
        customer_id: invoice.customer as string,
        amount: invoice.amount_paid / 100,
        currency: invoice.currency.toUpperCase(),
        transaction_id: invoice.id!,
        event_type: invoice.billing_reason === 'subscription_create' ? 'subscription' : 'renewal',
        payment_processor: 'stripe',
      });
    }
  }

  return new Response('ok');
}
```

If `track()` throws, the handler returns an error and Stripe retries. Supapulse keeps only the first event per `transaction_id` and event type. Divide Stripe amounts by 100 for two-decimal currencies; zero-decimal currencies like JPY are already in major units.

## Common issues [#common-issues]

**Nothing shows up.** Check the settings: if the last event time is empty, Stripe isn't reaching the endpoint. A `404` in Stripe's delivery log means the signing secret isn't saved. A `400` means it's the wrong secret.

**Revenue shows up as direct.** Supapulse found no visit for the customer. Call `identify()` after signup, and pass `supapulse_customer_id` in the checkout metadata. Supapulse only looks back over the site's salt rotation window, 1 to 30 days.

**Revenue counted twice.** Both the built-in webhook and your own handler are sending the same payments. Keep one.

**The `200` says `plan_inactive`.** The team's trial ended or its plan lapsed. Events arriving in that time aren't stored.
