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:
| Event | When it fires | What Affitor does |
|---|---|---|
checkout.session.completed | First payment (one-time or first subscription payment) | Records the sale; looks up the lead by affitor_customer_key |
invoice.payment_succeeded | Every subscription renewal | Records the recurring sale using the Subscription's metadata |
Attribution fallback chain. Affitor resolves the partner in this order:
affitor_click_id— direct click attribution (most reliable)- Email — matched against the lead record if
affitor_click_idis missing - Stripe customer ID — matched if the same customer appeared in a previous attributed session
affitor_customer_keyalone — 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.paidevents withamount_paidof0. The SDK rejects a non-positive amount, so guard against it. - Stripe Basil metadata path. Stripe API version Basil (
2025-03-31+) movedsubscription_detailsunderinvoice.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
- Visit your site with `?aff=TESTCODE` — an `affitor_click_id` cookie is set
- At checkout, confirm the Stripe Session metadata contains `affitor_click_id`, `affitor_customer_key`, and `program_id`
- The sale appears under your program's tracking events with the partner correctly attributed
- Add `additional_data: { test_mode: true }` to a server-side `trackSale()` call to create test events without issuing real commissions
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.