Guide · Stripe Checkout

UTM tracking with Stripe Checkout

UTM tags vanish the moment a visitor leaves for checkout.stripe.com. This guide keeps them: capture on landing, carry into the Checkout Session, read back from the webhook.

By Ahmed Gagan, maker of DataBull · Published · 2 min read

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:

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.

Questions

Why not put the UTM tags in client_reference_id?

It's one string of up to 200 characters, limited to letters, numbers, dashes and underscores, so a full set of tags doesn't fit cleanly. Metadata takes up to 50 named keys and can be passed on to the subscription.

Should I use localStorage instead of cookies?

Your server can't read localStorage when it creates the session, so you'd have to send the values in the request yourself. Cookies arrive with the request on their own, which keeps the server code simple.

Do I need checkout.session.completed and invoice.paid?

For subscriptions, yes: the session event covers the first payment and invoice.paid with billing_reason subscription_cycle covers each renewal. For one-time payments, the session event is enough.

Will this catch buyers who block scripts?

No. If the capture script never runs, the cookie is empty and the sale arrives without a source. Serving your analytics script from your own domain reduces how often that happens.

Find out which of your posts pay the bills.

One script, one payment connection, and you'll know by tomorrow.