Integrations

Stripe

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

View as Markdown

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.

Info

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

Info

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

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',
    },
  },
});
Info

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:

EventWhen it firesWhat Affitor does
checkout.session.completedA one-time paymentRecords the sale. A subscription checkout records no sale here: its first invoice does
invoice.payment_succeeded, invoice.paidA subscription's first payment and every renewalRecords 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:

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;
}
Info

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:

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
  • 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

Common mistakes
  • 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.
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 →

Share feedback

Tell us what is missing or unclear on “Stripe”.

Last updated Oct 18, 2026