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 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 (dashboard → program settings)
  • A program API key (dashboard → program settings → API keys)
  • Stripe Checkout or Stripe Billing already working in your app
  • Affitor receiving your Stripe webhooks (dashboard → program settings → Stripe connect)

1. Capture clicks and track signups

These two steps are identical to the framework integration. Follow Step 1 and Step 2 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')

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 you must attach the metadata in two places: metadata (covers the first payment via checkout.session.completed) and subscription_data.metadata (covers every renewal via invoice.payment_succeeded).

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',
  },
  // Required for renewals — omitting this loses partner attribution after the first payment
  subscription_data: {
    metadata: {
      affitor_click_id:      clickId,
      affitor_customer_key:  user.id,
      program_id:            'YOUR_PROGRAM_ID',
    },
  },
});

:::note Do not skip subscription_data.metadata. Stripe copies metadata onto the Checkout Session object, not onto the Subscription. Renewals fire as invoice.payment_succeeded events against the Subscription — if the Subscription's metadata is empty, Affitor cannot attribute the renewal to any partner. :::

3. How Affitor attributes payments

Affitor processes two Stripe webhook events:

EventWhen it firesWhat Affitor does
checkout.session.completedFirst payment (one-time or first subscription payment)Records the sale; looks up the lead by affitor_customer_key
invoice.payment_succeededEvery subscription renewalRecords the recurring sale using the Subscription's metadata

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. 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:

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 you use Stripe Connect (Affitor's own webhook records the sale), renewals are autocaptured server-side — do not also call trackSale, or you will double-count. :::

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:

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

Verify success
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
If it doesn't work
  • Add `additional_data: { test_mode: true }` to a server-side `trackSale()` call to create test events without issuing real commissions

Common mistakes

Common mistakes
  • Omitting `subscription_data.metadata` — the first payment attributes but every renewal is lost because Affitor reads the Subscription's own metadata for `invoice.payment_succeeded` events.
  • 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.
Next recommended step
Using Express or a non-Next.js server?

See the Node / Express integration guide for server-side wiring without a framework.

Continue
Edit on GitHub
© 2026 Affitor