Use this when your server creates the checkout — the usual shape for a custom pricing page.
The visitor id is in a cookie your server can read, so it goes in when the checkout is created, before the customer sees anything.
LemonSqueezy nests this one level deeper than everyone else. The field is
checkout_data.custom, not a top-level custom_data. Putting the ids at the
top level is accepted by the API and silently dropped, which looks exactly
like a working integration until you notice every payment is unattributed.
// app/api/checkout/route.js
import { cookies } from "next/headers";
export async function POST() {
// `cookies()` is only readable on the server — this is why the checkout is
// 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: a visitor with cookies blocked has none,
// and an empty string would store an id that matches no visitor.
const custom = {};
if (ts_vid) custom.ts_vid = ts_vid;
if (ts_vs) custom.ts_vs = ts_vs;
const response = await fetch("https://api.lemonsqueezy.com/v1/checkouts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
},
body: JSON.stringify({
data: {
type: "checkouts",
attributes: {
checkout_data: {
// HERE — inside `checkout_data`, not beside it.
custom,
},
},
relationships: {
store: { data: { type: "stores", id: process.env.LEMONSQUEEZY_STORE_ID } },
variant: { data: { type: "variants", id: "VARIANT_ID" } },
},
},
}),
});
const { data } = await response.json();
return Response.json({ url: data.attributes.url });
}Why this is the best of the three
The ids are recorded at the moment of purchase, by your own server, from a cookie the browser cannot forge. Nothing depends on the customer coming back to your site afterwards.
It also carries forward: the custom data stays on the subscription, so every renewal for the life of that subscription is attributed to the visit that won the customer — not just the first payment.
If everything arrives unattributed
Almost always the nesting. Check that custom sits inside checkout_data:
// ✅ right
attributes: { checkout_data: { custom: { ts_vid: "..." } } }
// ✗ wrong — accepted, then dropped
attributes: { custom: { ts_vid: "..." } }The second is not rejected. LemonSqueezy takes the request, creates the checkout and discards the field, so the only symptom is an empty Unattributed row filling up.
Otherwise, check you are reading the cookie at the moment the customer clicks rather than when the page was built — on a first visit the tag may not have run yet.