How to track attribution through Stripe Checkout
A Stripe Checkout Session holds 200 characters of reference ID and 50 metadata pairs but no UTM code. Add a visitor ID before the redirect and read it back.
Muzahid Maruf, FounderUpdated
On this page
Explore with AI
Opens this article inside the chosen assistant with a ready-made prompt.
Stripe Checkout attribution tracking comes down to one handoff: your server puts a visitor ID on the Checkout Session before the redirect, and your webhook reads it back when the payment completes.
A Checkout Session has no UTM, referrer or landing page field, and Stripe's OpenAPI spec for API version 2026-09-30 has no UTM field on any object.
Stripe's Checkout lifecycle has 4 steps: your server creates the Session, the buyer is redirected to its URL, the buyer pays, and a webhook reports the payment. Attribution gets written in step 1 and read in step 4.
Key takeaways
- A Stripe Checkout Session returns only what your server wrote at creation: one client_reference_id of up to 200 characters and up to 50 metadata pairs. UTM codes have no field.
- Create the Session when the buyer clicks Buy, record the link on checkout.session.completed and revenue on invoice.paid, keyed by subscription ID so payment 12 of a $49 plan credits the channel of payment 1.
- Success-page counts miss buyers who pay and close the tab, and Stripe retries a failed webhook for up to 3 days, so count sales from the webhook.
- Safari deletes JavaScript-written cookies after 7 days without interaction, so save the visitor ID on the user record at signup.
What a Checkout Session keeps and what it drops
A Checkout Session returns the attribution data your server wrote at creation and nothing else about the visit.
| Field | Set with | Comes back on | Limit |
|---|---|---|---|
| Reference ID | client_reference_id | The Session and checkout.session.completed | 200 characters |
| Session metadata | metadata | The Session and its events | 50 pairs, 40-character keys, 500-character values |
| Payment-mode copy | payment_intent_data.metadata | The PaymentIntent | Same 50, 40 and 500 limits |
| Subscription-mode copy | subscription_data.metadata | The Subscription | Same 50, 40 and 500 limits |
| Return URL | success_url with {CHECKOUT_SESSION_ID} | The URL the buyer lands on | Stripe fills in the Session ID |
| Buyer email | customer_email, to prefill | customer_details.email | 800 characters |
| UTM codes, referrer, landing page | No parameter exists | Nowhere | 0 fields |
Limits from Stripe's Create a Checkout Session reference and metadata guide, October 2026.
Stripe's metadata guide says metadata doesn't copy to related objects on its own, so a source written only on the Checkout Session never reaches the Subscription or its invoices. The Stripe metadata setup guide lists the copy rules.
Why analytics tools lose the buyer at Checkout
| Where it breaks | What happens | Number to know | Fix |
|---|---|---|---|
| Payment page | Your tags don't run on Stripe's page, including one served from a custom domain such as payments.example.com | 1 custom domain per account | Write the ID into the Checkout Session |
| Return trip | Google Analytics 4 files the visit as a referral | 50 unwanted referrals per stream | Exclude checkout.stripe.com |
| Success page | Counts miss paid buyers and repeat on refresh | 3 days of webhook retries | Count from the webhook |
| Safari | Cookies created in JavaScript are deleted | 7 days without interaction | Set the cookie from your server |
Stripe's Google Analytics 4 funnel guide tags only your product, success and canceled pages, so a client-side tag sees the Buy click and the return trip.
It says to add checkout.stripe.com to the referral exclusion list, which Google's unwanted referrals page documents under Admin, Data streams, Configure tag settings. The GA4 revenue-by-channel guide covers the revenue mismatch.
WebKit's tracking prevention page says Intelligent Tracking Prevention deletes cookies created in JavaScript, plus localStorage and other script-writable storage, after 7 days without the visitor interacting with the site.
A reader who clicks a newsletter link on Monday and next opens the site 9 days later arrives with no stored ID.
I'd set the cookie from your server and save the visitor ID on the user record at signup, since SaaS buyers usually register before they pay. The Safari ITP post covers what else survives.
Stripe Checkout attribution tracking, step by step
Build against a Stripe sandbox, where test card 4242 4242 4242 4242 with any future expiry and any 3-digit CVC completes a payment, and run stripe listen --forward-to localhost:4242/webhook to receive events locally.
Step 1: Give every visitor an ID and save the first visit
On the first page view, create a 36-character UUID, set it as a cookie from your server, and store the landing URL, referrer and 5 UTM parameters against it. Write first touch once and let last touch update.
Use a random ID in place of an email address, since an anonymous visitor has no email yet. The stored referrer covers links whose UTM codes a shortener stripped (why UTM parameters get stripped).
Step 2: Create the Session when the buyer clicks
Create the Checkout Session inside the click handler, because one built when the pricing page loads carries whatever the cookie held at that moment and can sit open for 24 hours.
Per Stripe's redirect guide, hosted pages take a success_url and embedded Checkout takes a return_url. Embedded Checkout renders on your page, so your cookie and tags stay in place while the buyer pays.
// vid is the visitor ID from your first-party cookie; visit is the row saved in step 1app.post("/create-checkout-session", async (req, res) => { const vid = req.cookies.vid; const visit = await getVisit(vid); const session = await stripe.checkout.sessions.create({ mode: "subscription", line_items: [{ price: "price_123", quantity: 1 }], client_reference_id: vid, // 36 characters, the limit is 200 metadata: { vid }, // visible on the Session itself subscription_data: { // copied onto the Subscription metadata: { vid, first_touch: visit.firstSource, last_touch: visit.lastSource }, }, // payment mode takes payment_intent_data.metadata in place of subscription_data // embedded Checkout takes return_url in place of success_url success_url: "https://example.com/welcome?session_id={CHECKOUT_SESSION_ID}", cancel_url: "https://example.com/pricing", }); res.redirect(303, session.url);});Step 3: Record the link from the webhook and the revenue from invoices
The funnel guide notes that success-page counts miss purchases when the redirect fails and over-count refreshes, and Stripe's fulfillment guide requires webhooks for that reason.
According to the webhook guide, live-mode deliveries retry for up to 3 days, the same event can arrive twice, and order isn't guaranteed, so verify the Stripe-Signature header, log event IDs and key rows by object ID.
Bank debits and vouchers can take 2 to 14 days to confirm, as Stripe's Payment Links tracking page says, so also listen for checkout.session.async_payment_succeeded.
A subscription's first invoice repeats the $49 the Checkout Session reports, so pick one revenue source.
I'd count revenue from invoices and use the Checkout Session only to learn who the buyer was, which sends payment 1 and payment 12 down the same path.
A one-time payment has no invoice by default, so count the Checkout Session's amount_total once payment_status is paid.
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => { let event; try { event = stripe.webhooks.constructEvent( req.body, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET); } catch (err) { return res.sendStatus(400); } if (await seenEvent(event.id)) return res.sendStatus(200); // deliveries can repeat const obj = event.data.object; if (event.type === "checkout.session.completed" && obj.client_reference_id) { await saveLink({ sessionId: obj.id, subscriptionId: obj.subscription, // null in payment mode vid: obj.client_reference_id, email: obj.customer_details?.email, // fallback join key }); } if (event.type === "invoice.paid") { await saveRevenue({ invoiceId: obj.id, subscriptionId: obj.parent?.subscription_details?.subscription ?? obj.subscription, // older API versions cents: obj.amount_paid, reason: obj.billing_reason, // subscription_create, then subscription_cycle }); } res.sendStatus(200);});When a webhook endpoint listens for checkout.session.completed and a success URL is set, Checkout waits up to 10 seconds for your response before redirecting the buyer, so write the link row first and defer slow work.
Step 4: Carry the channel to renewals and refunds
A renewal creates no Checkout Session, so it earns its channel through the subscription ID saved in step 3.
A charge.refunded event carries a PaymentIntent, and Stripe's Invoice Payments API can look up the invoice that PaymentIntent paid, which leads back to the subscription and the channel to debit. The subscription LTV guide covers lifetime revenue by channel.
Step 5: Test the chain before you trust it
Run a sandbox subscription checkout, retrieve the Checkout Session to confirm the visitor ID, and check that both events reached your tables with 1 shared subscription ID.
Then delete the cookie and run a second checkout to exercise the email fallback.
A worked example with numbers
Take 40 new subscribers on a $49 plan, which adds $1,960 of MRR. With an ID on every Checkout Session the revenue splits by channel, and unmatched buyers stay visible as their own row.
| Channel | New subscribers | New MRR | Share of new MRR |
|---|---|---|---|
| Newsletter sponsorship | 14 | $686 | 35.0% |
| Google Ads | 9 | $441 | 22.5% |
| Affiliate links | 6 | $294 | 15.0% |
| Organic search | 5 | $245 | 12.5% |
| No ID found | 6 | $294 | 15.0% |
| Total | 40 | $1,960 | 100% |
Illustrative figures, not customer data.
One newsletter subscriber who stays 12 months pays $588, yet a report built only from Checkout Sessions credits the newsletter with $49.
| Month | Invoices paid | Credited with the subscription join | Credited from Sessions only |
|---|---|---|---|
| 1 | 1 | $49 | $49 |
| 3 | 3 | $147 | $49 |
| 6 | 6 | $294 | $49 |
| 12 | 12 | $588 | $49 |
Illustrative figures for one $49 subscriber.
The 6 unmatched buyers in the first table stand for $3,528 a year (6 × $588), and the newsletter's 14 subscribers are worth $8,232 (14 × $588) if none cancels, the figure to weigh against what the sponsorship cost.
What this setup leaves uncovered
Buyers who click on a phone and pay on a laptop arrive with a second cookie, so only the email they registered with can join them.
Payment Links skip your server-side Session creation and need the ID added another way, which the Payment Links attribution guide walks through.
What TrackRev does with a Checkout Session
TrackRev is SaaS affiliate software with link tracking and revenue attribution built in, and its pixel writes a first-party cookie named vid that lasts 365 days.
Pass it as client_reference_id when your server creates a Checkout Session; the Stripe integration page covers the pixel and the restricted key.
Because the pixel writes the cookie from JavaScript, WebKit's 7-day rule applies, so call trk.identify(email) at signup to enable an email match as well.
TrackRev reads Stripe with a restricted key and checks 4 places for the visitor ID, in this order.
| Order | Where the sync looks |
|---|---|
| 1 | metadata.vid on the charge |
| 2 | client_reference_id on the Checkout Session |
| 3 | metadata.vid on the Checkout Session |
| 4 | The buyer's email, once identify() has run |
Lookup order in TrackRev's Stripe sync.
A renewal inherits the first order's visitor through the subscription ID, and a refunded charge flips its order to refunded on the next sync.
The sync runs hourly, or on 3 webhook events (charge.succeeded, charge.refunded and checkout.session.completed) when the key allows webhook writes. Revenue figures need a paid plan, from Indie at $29 a month (pricing), with last-touch, first-touch or linear credit.
The free plan covers link tracking with 50 links and 1,000 tracked events a month.
Found this useful? Share it.
Frequently asked questions
- No. A Checkout Session has no field for the 5 UTM codes, so they vanish at the redirect unless your server copies them into client_reference_id or metadata first. Payment Links accept the same 5 codes at up to 150 characters each, but return them only on your redirect URL.
- Yes. The limit is 200 characters, so a UUID uses 36 of them. Payment Links and Pricing Tables accept only letters, digits, dashes and the _ character, and silently drop an invalid value, which a UUID satisfies.
- No charge is made, so there is nothing to attribute. An unpaid Session expires after 24 hours by default, or 30 minutes at the shortest, and the next click creates a new one. Log the visitor ID at creation to compare Sessions started with Sessions paid for every channel.
- It is paid, unpaid or no_payment_required. For a subscription with a free trial, paid means the $0 trial invoice was processed.
- Automatic retries last up to 3 days in live mode, so the earliest events from the outage need a manual fix. Resend events from the Stripe Dashboard within 15 days of creation or with the Stripe CLI within 30 days, or list recent Checkout Sessions through the API and backfill the missing rows.
- You can build it: one route, one webhook handler and a visitor table cover the first sale. Renewals, refunds, deduplication and a channel report are the ongoing work, and TrackRev, affiliate software with this tracking built in, covers them for Stripe and 5 other billing systems from $29 a month on Indie. If Stripe is your only processor and one hand-built report is enough, building it yourself is reasonable.

Written by
Founder, TrackRev.io & Contant.io
Muzahid Maruf founded TrackRev.io, SaaS affiliate software with no limit on tracked revenue, and Contant.io. He writes about affiliate programs.
Writes about Marketing attribution · Link tracking · Revenue analytics · SaaS growth
Stop guessing where your revenue comes from.
Set up TrackRev in about five minutes. The free plan covers 1,000 events a month, no card needed.
Start free