Skip to main content

Stripe

Checkout Session

Your server creates the session, and the visitor id goes in as metadata.

Use this when your server creates the Checkout Session — the usual shape for a custom pricing page.

The visitor id is in a cookie your server can read, so it goes in as metadata when the session is created, before the customer sees a checkout at all.

Next.js
// app/api/checkout/route.js
import Stripe from "stripe";
import { cookies } from "next/headers";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST() {
  // `cookies()` is only readable on the server — this is why the checkout
  // session has to be created here and not in the browser.
  //
  // It is asynchronous from Next.js 15 on. Version 14 returns it directly,
  // so drop the `await` there.
  const cookieStore = await cookies();

  const ts_vid = cookieStore.get("ts_vid")?.value;
  const ts_vs = cookieStore.get("ts_vs")?.value;

  // Left out entirely when absent: Stripe rejects a metadata value of
  // `undefined`, and a visitor with cookies blocked simply has none.
  const attribution = {};
  if (ts_vid) attribution.ts_vid = ts_vid;
  if (ts_vs) attribution.ts_vs = ts_vs;

  const session = await stripe.checkout.sessions.create({
    mode: "subscription", // or "payment" for a one-off
    line_items: [{ price: "price_123", quantity: 1 }],
    success_url: "https://yoursite.com/thanks",
    cancel_url: "https://yoursite.com/pricing",

    metadata: attribution,

    // Subscriptions:
    subscription_data: { metadata: attribution },

    // One-off payments — use INSTEAD of subscription_data, never both:
    // payment_intent_data: { metadata: attribution },
  });

  return Response.json({ url: session.url });
}

Two places, not one. Stripe copies metadata between related objects nowhere. The top-level metadata reaches the checkout event and stops there — it never lands on the payment or the invoice.

The second place depends on what you sell, and they are mutually exclusive:

you sellsecond place
one-off paymentspayment_intent_data
subscriptionssubscription_data

Sending both on a subscription checkout is an error from Stripe, because the payment intent belongs to the invoice.

subscription_data is the one that matters most: Stripe copies it onto every future invoice, so a renewal two years from now is still attributed to the campaign that won the customer. No cookie survives that long.

A visitor with no cookie is normal. Someone with cookies blocked, or in a fresh private window, has no ts_vid — which is why the examples build the object conditionally. The payment still records; it shows as unattributed, which is honest.

Was this page helpful?

Last updated September 8, 2026