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.
// 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 sell | second place |
| one-off payments | payment_intent_data |
| subscriptions | subscription_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.