Short answer
Read utm_* and the referrer when the visitor lands and keep them in a first-party cookie. When your server creates the Checkout Session, copy them into metadata, plus subscription_data.metadata or payment_intent_data.metadata. Your checkout.session.completed webhook then receives them, and renewal invoices carry them in parent.subscription_details.metadata.
This is the do-it-yourself version, with code for a Next.js App Router project. The same steps work in any framework: a few lines in the browser, a few on the server, and a webhook.
1. Capture the source on landing
UTM tags only exist in the URL of the first page. Save them before the visitor clicks anywhere. This snippet keeps two touches in first-party cookies for 90 days: the first source that ever brought this visitor, and the last one that wasn't a plain direct visit.
<script>
(function () {
var q = new URLSearchParams(location.search);
var ref = '';
try { ref = document.referrer ? new URL(document.referrer).hostname : ''; } catch (e) {}
if (ref === location.hostname) ref = ''; // a click inside your own site is not a source
var touch = {
source: q.get('utm_source') || q.get('ref') || ref,
medium: q.get('utm_medium') || '',
campaign: q.get('utm_campaign') || '',
content: q.get('utm_content') || '',
landing: location.pathname,
at: new Date().toISOString().slice(0, 10)
};
if (!touch.source && !touch.campaign) return; // direct visit: keep what we had
function save(name) {
document.cookie = name + '=' + encodeURIComponent(JSON.stringify(touch)) +
'; Max-Age=7776000; Path=/; SameSite=Lax; Secure';
}
if (!/(?:^|; )ft=/.test(document.cookie)) save('ft'); // first touch: written once
save('lt'); // last non-direct touch
})();
</script>
Put it in your root layout so it runs on every page. Two notes:
- First vs last touch answer different questions. First touch tells you what finds buyers; last non-direct touch tells you what brings them back to pay. Keeping both costs nothing.
- Consent. These are first-party cookies used for measurement. Depending on where your visitors are, you may need consent before setting them.
2. Pass it into the Checkout Session
Your server reads the cookies and copies them into the session's metadata. Metadata values are strings up to 500 characters, with up to 50 keys.
The important line is the second copy. Stripe does not copy session metadata to the subscription or the PaymentIntent. Without subscription_data.metadata, your renewals arrive with no source. Note that subscription_data only works in subscription mode and payment_intent_data only in payment mode.
// app/api/checkout/route.js
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const touch = (request, name) => {
try {
return JSON.parse(decodeURIComponent(request.cookies.get(name)?.value || '{}'));
} catch {
return {};
}
};
// Stripe metadata: string values, 500 characters max. Empty values are left out.
const clean = (obj) => Object.fromEntries(
Object.entries(obj).filter(([, v]) => v).map(([k, v]) => [k, String(v).slice(0, 500)])
);
export async function POST(request) {
const first = touch(request, 'ft');
const last = touch(request, 'lt');
const metadata = clean({
first_source: first.source, first_campaign: first.campaign, first_landing: first.landing,
last_source: last.source, last_medium: last.medium, last_campaign: last.campaign,
});
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }],
metadata,
subscription_data: { metadata }, // one-time payments: payment_intent_data: { metadata }
success_url: `${new URL(request.url).origin}/thanks?session_id={CHECKOUT_SESSION_ID}`,
});
return Response.redirect(session.url, 303);
}
Point your buy button at it with a plain form, so it works without JavaScript:
<form action="/api/checkout" method="POST"><button>Subscribe</button></form>
3. Read it back from the webhook
When the payment completes, Stripe sends checkout.session.completed with the session and its metadata. Renewals arrive as invoice.paid, and the subscription's metadata is on the invoice under parent.subscription_details.metadata (a snapshot taken when the invoice was created).
// app/api/stripe-webhook/route.js
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
let event;
try {
event = stripe.webhooks.constructEvent(
await request.text(),
request.headers.get('stripe-signature'),
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return new Response('bad signature', { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const s = event.data.object;
await saveSale({ id: s.id, amount: s.amount_total, currency: s.currency, ...s.metadata });
}
if (event.type === 'invoice.paid' && event.data.object.billing_reason === 'subscription_cycle') {
const inv = event.data.object;
const meta = inv.parent?.subscription_details?.metadata || {};
await saveSale({ id: inv.id, amount: inv.amount_paid, currency: inv.currency, renewal: true, ...meta });
}
return new Response('ok');
}
saveSale is yours: a row in your database is enough. The first invoice of a subscription is skipped here because checkout.session.completed already counted it. Older Stripe API versions put the same data at invoice.subscription_details.metadata; check the API version your account and SDK use.
4. Report on it
With one row per payment, revenue by source is a group-by:
SELECT last_source, last_campaign, COUNT(*) AS sales, SUM(amount) / 100.0 AS revenue
FROM sales
GROUP BY last_source, last_campaign
ORDER BY revenue DESC;
Swap in first_source to see what finds buyers rather than what closes them. Still to handle on your own: refunds (listen for charge.refunded and subtract from the original sale), several currencies, and buyers whose cookie never reached the checkout.
Or skip all of this
DataBull's tracking script already records every visit's source, referrer and UTM tags, and keeps a first-party visitor id. Payment links are tagged automatically. For server-created checkouts, the whole of steps 1 to 4 becomes two metadata fields:
const metadata = {
databull_visitor_id: cookie('datacheap_visitor_id'),
databull_session_id: cookie('datacheap_session_id'),
};
// pass as metadata and subscription_data: { metadata }
DataBull reads the payment from Stripe (past sales import on connect, new ones by webhook), credits it with the attribution model you pick, lets renewals inherit the first payment's source, takes refunds off, and shows revenue by channel, referrer, campaign and landing page. Every page of the setup is in the revenue docs, and this guide covers payment links and PaymentIntents too.
Checked in October 2026 against: Stripe: Metadata (limits; subscription_data.metadata; invoice parent.subscription_details.metadata), Stripe API: Create a Checkout Session, Stripe: Payment Links URL parameters. Spotted something out of date? Tell us.