Everyone talks about third-party cookies dying like it's the 2026 attribution problem. It isn't. Third-party cookies have been useless for affiliate tracking for years. The thing that actually breaks affiliate programs now is quieter: the first-party cookie you set yourself gets capped at 7 days in Safari, and often wiped sooner.
That matters because affiliate windows don't fit in 7 days. A normal program credits an affiliate if the person buys within 30 or 60 days of the click. Someone clicks a creator's link, pokes around, leaves, and comes back three weeks later from a Google search to actually sign up. If your attribution lives in a browser cookie, that commission is already gone. The cookie expired on day 8.
So the real 2026 question is not "how do I track without cookies." It's "where does a 60-day fact live when no browser will hold it for 60 days." The answer is your server.
The chain you actually have to connect
A purchase that should pay an affiliate passes through three identity gaps:
- Click on the affiliate link (you know the affiliate, you don't know the person yet).
- Account signup, maybe days later (now you know the person, the click is ancient history).
- First real payment, maybe after a trial (now Stripe is involved, and Stripe has never heard of your cookie).
Lose the thread at any one of these and the commission resolves to nobody. Most "our affiliate numbers look low" bugs are one of these three joins quietly failing, not the tracking script.
Gap 1: click to signup
On click, do two things, not one. Set the first-party cookie like always, but also write the visit down server-side. Redirect through your own endpoint so the ref never depends on the browser surviving:
GET /r/:affiliateCode -> your server
- look up affiliateCode
- create a visit row: { visit_id, affiliate_id, clicked_at, landing_url }
- set a first-party cookie: ref_visit = visit_id (still useful inside 7 days)
- 302 to the destination with ?ref=affiliateCode preserved
The cookie is now a convenience, not the source of truth. If it survives to signup, great, you read visit_id straight off it. If it doesn't, you still have the ?ref= in the URL the user landed on, and you can stash that in localStorage as a backup. The point is to stop betting the whole commission on one storage mechanism that a browser is actively trying to delete.
At signup, resolve whatever you have (cookie, then URL ref, then localStorage) to an affiliate and write it onto the user row immediately:
UPDATE users
SET referred_by_affiliate_id = $1,
referred_at = now()
WHERE id = $2 AND referred_by_affiliate_id IS NULL;
Once the affiliate is on the user row, the browser is out of the loop forever. This is the move people skip. They keep reading the cookie at purchase time, months later, and wonder why it's empty.
Gap 2: signup to payment, where Stripe earns its keep
Stripe gives you a deterministic handle that needs no cookie: client_reference_id on the Checkout Session (or metadata if you create subscriptions directly). Put your own identifier there when you start checkout:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: user.id, // your join key, not the cookie
subscription_data: {
metadata: { referred_by_affiliate_id: user.referred_by_affiliate_id ?? "" },
},
success_url: "...",
cancel_url: "...",
});
Now the affiliate fact rides along with the payment itself. When checkout.session.completed fires, you read client_reference_id, load the user, and you already know the affiliate from Gap 1. No fingerprinting, no cookie, no guessing.
Putting the affiliate id on subscription_data.metadata too is worth the extra line. It means every future event for that subscription, renewals, upgrades, cancellations, refund clawbacks, can resolve the affiliate without another database lookup. The fact travels with the object it describes.
Gap 3: the trial that hides the attribution
If you run trials, attribute at subscription creation, not at the first paid invoice. A 14-day trial means the first invoice.paid lands two weeks after signup, and if you only stamp the affiliate when money moves, you have created a brand new window problem on top of the cookie one.
Stamp the affiliate onto the Stripe Customer when the customer is created, then let invoices inherit it:
await stripe.customers.update(customer.id, {
metadata: { referred_by_affiliate_id: user.referred_by_affiliate_id ?? "" },
});
Commission gets calculated when the trial converts to a paid invoice, but attribution was decided up front, while you still knew who sent them.
Webhooks: resolve, don't trust the order
One caution that bites people here. Stripe does not promise webhook events arrive in the order they happened, and it can deliver the same event more than once. So treat the webhook as a "go look" signal, re-fetch the object from the API if you need its current state, and make the commission write idempotent on the Stripe event id:
INSERT INTO commission_events (stripe_event_id, subscription_id, amount)
VALUES ($1, $2, $3)
ON CONFLICT (stripe_event_id) DO NOTHING;
Without that, a retried invoice.paid pays the affiliate twice, and you find out at payout time.
Last touch, and the window that finally lives somewhere real
Two affiliates can send the same person. Pick a rule (last touch inside the window is the common one) and enforce it in your own data, because now you can. The visit rows from Gap 1 have timestamps. At signup you pick the most recent click within your 60-day window and bind that affiliate. The window is a WHERE clicked_at > now() - interval '60 days', not a cookie Max-Age that Safari overrides anyway.
That is the whole shift. The browser holds a 7-day hint. Your server holds the 60-day truth. Stripe carries the join key through the payment so the two ends meet without anything client-side surviving the trip.
I build Referralful, Stripe-native affiliate software for SaaS, so this is the exact plumbing we deal with daily. The patterns above are general though, they work the same whether you wire it yourself or not. Happy to answer Stripe attribution questions in the comments.
Top comments (0)