How affiliate tracking actually works with Stripe: cookies, webhooks, and clawbacks
If you sell through Stripe and you're setting up an affiliate program, or building the tracking yourself, there are really four problems to solve: getting a referral code onto the visitor, getting that code onto the Stripe object, hearing about the payment reliably, and handling refunds without paying out on money that came back. This is how each piece actually works.
1. Getting the code onto the visitor
An affiliate link looks like yoursite.com/?via=CODE or points at a short redirect like yoursite.com/r/CODE. The redirect version is nicer because it never shows the raw code in the address bar and lets you log the click server-side before bouncing the visitor onward.
Once the visitor lands, you need to remember that code until they convert, which might be minutes later or weeks later. The standard approach is a first-party cookie, something like ambassly_ref, set for 60 days with SameSite=Lax, alongside a localStorage mirror as a backup read path.
Here's the part most tutorials skip: if you set that cookie from client-side JavaScript, Safari's Intelligent Tracking Prevention caps its life at about seven days, regardless of what expiry you asked for. A visitor who bookmarks a tutorial and comes back to buy three weeks later on Safari will silently lose attribution. The fix is setting the cookie via an HTTP Set-Cookie response header from your server instead of document.cookie, since ITP's cap applies specifically to client-set cookies, not server-set ones. If you can't do that for every request, at minimum be honest with your affiliates about the limitation instead of quietly under-reporting their conversions.
2. Getting the code onto the Stripe object
A cookie only helps you until the moment of purchase. After that, you need the code to travel with the actual payment, because cookies don't survive redirects to Stripe-hosted checkout and back in every browser configuration.
Stripe gives you two places to put it:
client_reference_idon a Checkout Session, a single string field designed for exactly this: attaching your own reference to a payment.metadata, a free-form key/value object available on Checkout Sessions, PaymentIntents, and Subscriptions.
Using both is the safest pattern. Set client_reference_id to the affiliate code, and also set metadata.ambassly_ref (or whatever key you choose) to the same value. client_reference_id is the more idiomatic single field, but metadata survives onto downstream objects like invoices in ways that are sometimes more convenient to query, so belt and suspenders costs you nothing here.
const session = await stripe.checkout.sessions.create({
client_reference_id: referralCode,
metadata: { ambassly_ref: referralCode },
line_items: [...],
mode: "subscription",
success_url: "...",
cancel_url: "...",
});
3. Hearing about it reliably: webhooks and idempotency
Now you need to know when that Checkout Session actually completes, and when subsequent invoices get paid if it's a subscription. That means a webhook endpoint listening for at minimum:
checkout.session.completed, to resolve the code and create the initial referral recordinvoice.paid, to create a commission each billing cycle for recurring programscharge.refundedandcustomer.subscription.deleted, to trigger clawbacks
The detail that actually matters here is idempotency. Stripe's own webhook docs are explicit that your endpoint will occasionally receive the same event more than once, because Stripe retries on anything other than a clean 2xx response, and network blips happen on both ends. If your handler isn't idempotent, a retried invoice.paid event creates a second commission for the same invoice, and now you owe an affiliate twice for one payment.
The fix is simple: keep a table of processed Stripe event IDs (stripe_event_id, unique constraint) and insert into it before you do anything else in the handler. If the insert fails on the unique constraint, you've seen this event before, so skip it and return 200. Every downstream side effect, creating a commission, updating a referral status, should happen only after that insert succeeds.
4. Refund clawbacks
The last piece is the one every affiliate program eventually needs and few build up front: what happens when the customer gets a refund, or cancels and the payment reverses. If you've already marked a commission as pending or approved and the underlying charge gets refunded, that commission needs to move to a clawed-back state, not just quietly stay on the books.
If the commission was already paid out before the refund landed, you can't un-send money, so the honest approach is a negative adjustment: a new ledger entry that nets against the affiliate's next payout. Either way, this only works if every commission has an event trail, not just a current status, so you (and your affiliate, if they ever ask) can see exactly why a commission changed.
This is also why a hold period before payout matters. If you wait 30 to 45 days before a commission is eligible for payout, roughly matching your refund window, most refunds land before you've paid anyone, which avoids the clawback-after-payout case almost entirely.
Building it versus buying it
None of this is exotic. It's a cookie, two Stripe fields, an idempotent webhook handler, and a state machine with an audit trail. Plenty of teams build it themselves in a weekend and it works fine for a while. Where it tends to break is exactly the corners above: the Safari cookie cap, the retried webhook, the refund that arrives after a payout already went out. We built Ambassly specifically so those three failure modes are handled once, correctly, instead of every dev-tool team rediscovering them independently.
If you're building this in-house, Stripe's own docs on Connect and on idempotent request handling are worth reading end to end before you ship your first webhook handler. It's an afternoon well spent either way.