Integrations
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.
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.
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',
},
},
});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:
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
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 metadataaffitor_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;
}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
- Visit your site with
?aff=TESTCODE— anaffitor_click_idcookie is set
- At checkout, confirm the Stripe Session metadata contains
affitor_click_id,affitor_customer_key, andprogram_id
- The sale appears under your program's tracking events with the partner correctly attributed
- Send a test sale straight to
POST /api/v1/track/salewithadditional_data: { test_mode: true }(the SDK'strackSale()has no field for it); a test sale creates no commission
Common mistakes
- Putting the click only in the session
metadataof a subscription checkout. Affitor records subscription payments from invoices, which carrysubscription_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_keymust equal thecustomerExternalId/customer_keyyou sent at signup. - Reading
affitor_click_idfrom the wrong place on the server — it is a browser cookie, not a query param. Forward it fromreq.cookiesorcookies()in your server handler. - Tracking a sale from the browser — sale tracking is server-side only. Stripe metadata and
@affitor/sdk/serverboth run on your server. - Using a different
program_idthan the one in yourinit()call — all three metadata fields must reference the same program.