Every affiliate program has the same first hard problem: when a payment succeeds, which affiliate gets the credit? The tracking link and the Stripe payment are two different worlds, and nothing connects them unless you deliberately carry the attribution through. Here is how to thread it end to end on Stripe.
The chain you have to preserve
A referred signup passes through several Stripe objects, and the affiliate id has to survive every hop:
click (cookie) -> Checkout Session -> Subscription -> Invoice -> Charge
Drop the id anywhere in that chain and the webhook that finally tells you money moved has no idea who to pay.
Start at Checkout
When you create the Checkout Session, stamp the affiliate id on it. Two fields matter:
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: PRICE_ID, quantity: 1 }],
// handy, but lives only on the session
client_reference_id: affiliateId,
// propagates onto the Subscription you are about to create
subscription_data: {
metadata: { affiliate_id: affiliateId },
},
success_url: '...',
cancel_url: '...',
});
client_reference_id is convenient but it stays on the Session. The important one is subscription_data.metadata, because that copies the affiliate id onto the Subscription itself, which is the object every future renewal traces back to.
Read it off the invoice, not the session
For a subscription you do NOT want to grant commission on checkout.session.completed. That fires once, at signup, so you would miss every renewal. Listen to invoice.paid instead, which fires for the first payment AND every recurring one:
// webhook: invoice.paid
const invoice = event.data.object;
// pull the subscription to read the metadata you stamped at checkout
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
const affiliateId = subscription.metadata.affiliate_id;
if (!affiliateId) return; // not a referred customer
await recordCommission({
affiliate_id: affiliateId,
invoice_id: invoice.id,
amount_cents: Math.round(invoice.amount_paid * COMMISSION_RATE),
// first invoice or a renewal? billing_reason tells you
kind: invoice.billing_reason, // 'subscription_create' vs 'subscription_cycle'
});
billing_reason is what lets you enforce rules like commission for the first 12 months only, or first-payment bounties, without guessing.
The cookie-to-checkout gap
The one part Stripe cannot help with is getting the affiliate id onto the Checkout Session in the first place. That is on you: read the affiliate cookie on your server when the user clicks subscribe, and pass it into the session create call. Rely on a client-side value and an adblocker or a cleared cookie silently drops the attribution, so the affiliate never gets paid for a sale they earned.
Why metadata beats your own lookup table
You could keep a table mapping customer to affiliate. But subscriptions get their billing anchor changed, customers get merged, test data leaks into prod. Stamping the id directly on the Stripe object means the source of truth travels with the object. When you are debugging a missing commission six months later, subscription.metadata.affiliate_id is a five-second answer instead of a join across three tables.
(I work on Referralful, which does this attribution automatically on top of Stripe. The mechanics above are the same whether you build it or buy it.)
Top comments (0)