# UTM tracking with Stripe Checkout

Source: https://databull.app/guides/utm-tracking-stripe-checkout
Updated: 2026-10-30 · Author: Ahmed Gagan (maker of DataBull)

> 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.

**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](https://databull.app/docs/revenue), and [this guide](https://databull.app/guides/track-stripe-revenue-by-traffic-source) covers payment links and PaymentIntents too.

Checked in October 2026 against: [Stripe: Metadata (limits; subscription_data.metadata; invoice parent.subscription_details.metadata)](https://docs.stripe.com/metadata), [Stripe API: Create a Checkout Session](https://docs.stripe.com/api/checkout/sessions/create), [Stripe: Payment Links URL parameters](https://docs.stripe.com/payment-links/url-parameters).

## FAQ

### 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.
