---
id: "api-reference/integrations/stripe"
type: "doc"
url: "https://affitor.com/docs/api-reference/integrations/stripe"
kind: "guide"
updated: "2026-10-18"
---
# Stripe

> Canonical recipe for attributing Stripe payments to Affitor partners — one-time and recurring.

This guide is the **canonical reference for the Stripe sale path**. Other framework guides (Next.js, Express, etc.) link here for Step 3. If you need click capture or signup tracking first, start with the [Next.js guide](/docs/api-reference/integrations/nextjs) or the framework guide for your stack.

:::note

The `@affitor/sdk` package is **Beta**. The documented happy-path works; report issues on GitHub.

:::

## Prerequisites

- Your **program ID** (program menu → **Program settings** → **Business**)
- A **program API key** (program menu → **Program settings**, then **Developers** → **API keys**)
- Stripe Checkout or Stripe Billing already working in your app
- Your Stripe account connected to the program (program menu → **Program settings** → **Payments**, **Connect Stripe**). Affitor then receives the account's events through Stripe Connect; you do not add a webhook

## 1. Capture clicks and track signups

These two steps are identical to the framework integration. Follow [Step 1](/docs/api-reference/integrations/nextjs#1-capture-the-click) and [Step 2](/docs/api-reference/integrations/nextjs#2-track-the-signup) in the Next.js guide, or your framework's equivalent.

The key thing to take away: when you call `signup(userId)` or `trackLead({ customerExternalId: userId })`, keep that `userId` — you will attach it to every Checkout Session as `affitor_customer_key`.

## 2. Attach Affitor metadata to the Checkout Session

When you create a Stripe Checkout Session on your server, attach three metadata fields: `affitor_click_id`, `affitor_customer_key`, and `program_id`.

**How to read `affitor_click_id` server-side:** the browser SDK stores the click id in a first-party cookie called `affitor_click_id`. Read it from the incoming HTTP request and forward it to your server. For example, in a Next.js Route Handler you can read `cookies().get('affitor_click_id')?.value`. In a plain Express handler you can read `req.cookies.affitor_click_id`.

:::note

`affitor_customer_key` must be the **same stable user ID** you sent at signup — `signup(userId)` on the client, `customerExternalId: userId` on the server, and `affitor_customer_key: userId` in Stripe metadata are all the same field. Mismatching them breaks attribution.

:::

### One-time payment (`mode: 'payment'`)

```ts
const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: [{ price: 'price_xxx', quantity: 1 }],
  success_url: 'https://yoursite.com/success',
  cancel_url:  'https://yoursite.com/cancel',
  metadata: {
    affitor_click_id:      clickId,          // from the affitor_click_id cookie
    affitor_customer_key:  user.id,          // SAME id you used at signup
    program_id:            'YOUR_PROGRAM_ID',
  },
});
```

Affitor listens to the `checkout.session.completed` webhook event and attributes the payment automatically. No extra call needed.

### Subscription (`mode: 'subscription'`)

For subscriptions, put the same fields in `subscription_data.metadata`. Affitor records a subscription's first payment and each renewal from the invoice events (`invoice.payment_succeeded`, `invoice.paid`), and an invoice carries the subscription's metadata, not the Checkout Session's. Renewals do not depend on it: once the first payment is attributed, Affitor saves the buyer's Stripe customer ID and matches each renewal by the buyer's email or that Stripe customer ID.

```ts
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: 'price_xxx', quantity: 1 }],
  success_url: 'https://yoursite.com/success',
  cancel_url:  'https://yoursite.com/cancel',
  metadata: {
    affitor_click_id:      clickId,
    affitor_customer_key:  user.id,
    program_id:            'YOUR_PROGRAM_ID',
  },
  // The subscription's invoices (first payment and renewals) read the click from here
  subscription_data: {
    metadata: {
      affitor_click_id:      clickId,
      affitor_customer_key:  user.id,
      program_id:            'YOUR_PROGRAM_ID',
    },
  },
});
```

:::note

**Set `subscription_data.metadata` on subscription checkouts.** Stripe keeps `metadata` on the Checkout Session; only `subscription_data.metadata` reaches the Subscription and its invoices. Without it the click never reaches the invoice, and Affitor can attribute the subscription only by matching the buyer's email or Stripe customer ID to a customer it already has.

:::

## 3. How Affitor attributes payments

Affitor processes these Stripe webhook events for sales:

| Event | When it fires | What Affitor does |
|---|---|---|
| `checkout.session.completed` | A one-time payment | Records the sale. A subscription checkout records no sale here: its first invoice does |
| `invoice.payment_succeeded`, `invoice.paid` | A subscription's first payment and every renewal | Records the subscription sale. Both events go to the same handler, which records each invoice at most once |

**Attribution fallback chain.** Affitor resolves the partner in this order:

1. `affitor_click_id`: direct click attribution (most reliable)
2. Email: matched against the lead record if `affitor_click_id` is missing
3. Stripe customer ID: matched if the same customer appeared in a previous attributed session
4. `client_reference_id`: used only when the metadata has no click. The Affitor tracker writes the click there on Payment Links, the Buy Button and the Pricing Table, which cannot carry metadata
5. `affitor_customer_key` alone: matched against the lead record as a last resort

Supplying all three fields (`affitor_click_id`, `affitor_customer_key`, `program_id`) on every checkout is the recommended default — it ensures Affitor can attribute the sale even if the click cookie expired or was blocked.

## Subscription Renewals

If you self-host the Stripe webhook and call `trackSale` yourself (rather than relying on Affitor's Connect webhook), renewals need their **own handler**. The first payment fires as `checkout.session.completed` — every subsequent renewal arrives as a separate `invoice.paid` event with `billing_reason: 'subscription_cycle'`. Without a dedicated `case`, every renewal commission is silently missed.

Attribution rides on the `affitor_customer_key` you planted in `subscription_data.metadata` at checkout creation — Stripe copies that onto the Subscription, and Stripe stamps it onto every renewal invoice.

Two nuances matter:

- **Skip $0 renewals.** 100%-off coupons and fully credit-covered cycles produce `invoice.paid` events with `amount_paid` of `0`. The SDK rejects a non-positive amount, so guard against it.
- **Stripe Basil metadata path.** Stripe API version Basil (`2025-03-31`+) moved `subscription_details` under `invoice.parent`. Pre-Basil accounts still expose it at the legacy top level (`invoice.subscription_details`). Read the Basil path first and fall back to the legacy one so the handler works on both.

Add this as a separate `case` in the **same** Stripe webhook handler that processes `checkout.session.completed`:

```ts
case 'invoice.paid': {
  const invoice = event.data.object;
  // Only renewals — the first invoice is already counted at checkout.session.completed.
  if (invoice.billing_reason !== 'subscription_cycle') break;
  // Skip $0 renewals (100%-off coupons, fully credit-covered) — the SDK rejects a non-positive amount.
  if (!invoice.amount_paid || invoice.amount_paid <= 0) break;
  await affitor.trackSale({
    // Stripe Basil (2025-03-31+) moved subscription_details under invoice.parent;
    // read the Basil path first, fall back to the legacy top-level for pre-Basil accounts.
    customerExternalId: invoice.parent?.subscription_details?.metadata?.affitor_customer_key
      ?? invoice.subscription_details?.metadata?.affitor_customer_key,
    amount: invoice.amount_paid,            // integer cents
    invoiceId: invoice.id,                  // idempotency key — 409 = already recorded
    isRecurring: true,
    saleType: 'subscription',
    // Basil relocated the subscription id under invoice.parent.subscription_details;
    // fall back to the legacy top-level invoice.subscription for pre-Basil accounts.
    subscriptionId: invoice.parent?.subscription_details?.subscription
      ?? (typeof invoice.subscription === 'string' ? invoice.subscription : invoice.subscription?.id),
  });
  break;
}
```

:::note

This handler is only needed when you **self-host** sale tracking via `trackSale`. If Stripe is connected, Affitor already records each renewal from Stripe's events, so do **not** also call `trackSale`. Both record the renewal under the invoice id and Affitor refuses the second, but if your call lands first, its sale is not linked to the Stripe payment, so a refund in Stripe does not reverse its commission.

:::

## 4. Alternative: server-side tracking (non-Stripe)

If you are not using Stripe Checkout, or you need to report a sale from a different payment provider, call the server SDK directly after the payment is confirmed:

```ts
import Affitor from '@affitor/sdk/server';
const affitor = new Affitor({ apiKey: process.env.AFFITOR_API_KEY! });

await affitor.trackSale({
  customerExternalId: user.id,   // SAME id as signup
  amount:             4999,      // integer cents
  invoiceId:          invoice.id, // idempotency key — duplicate returns 409
  currency:           'usd',     // optional, defaults to usd
  isRecurring:        false,     // true for renewals
});
```

This path requires a Bearer API key and is called from your server only — never from the browser.

## Verify

<VerifySuccess
  browser={["Visit your site with `?aff=TESTCODE` — an `affitor_click_id` cookie is set"]}
  network={["At checkout, confirm the Stripe Session metadata contains `affitor_click_id`, `affitor_customer_key`, and `program_id`"]}
  dashboard={["The sale appears under your program's tracking events with the partner correctly attributed"]}
  ifNotWorking={["Send a test sale straight to `POST /api/v1/track/sale` with `additional_data: { test_mode: true }` (the SDK's `trackSale()` has no field for it); a test sale creates no commission"]}
/>

## Common mistakes

<CommonMistakes items={[
  "Putting the click only in the session `metadata` of a subscription checkout. Affitor records subscription payments from invoices, which carry `subscription_data.metadata`, not the session's; without it the subscription is attributed only if the buyer's email or Stripe customer ID matches a customer Affitor already has.",
  "Different customer IDs at signup vs checkout — `affitor_customer_key` must equal the `customerExternalId` / `customer_key` you sent at signup.",
  "Reading `affitor_click_id` from the wrong place on the server — it is a browser cookie, not a query param. Forward it from `req.cookies` or `cookies()` in your server handler.",
  "Tracking a sale from the browser — sale tracking is server-side only. Stripe metadata and `@affitor/sdk/server` both run on your server.",
  "Using a different `program_id` than the one in your `init()` call — all three metadata fields must reference the same program."
]} />

<NextStep title="Using Express or a non-Next.js server?" description="See the Node / Express integration guide for server-side wiring without a framework." href="/docs/api-reference/integrations/node-express" />
