The metadata-passthrough pattern (do this even if you build it yourself)
If you're rolling your own pipeline, the single most important pattern is to pass UTM data into the Checkout Session as metadata when you create it[2], then read those fields back in the checkout.session.completed webhook[3]. This is the only reliable bridge between the browser (which knows the UTMs[5]) and Stripe (which knows the payment). The Stripe docs are explicit: "You can't rely on triggering fulfillment only from your checkout landing page"[4].
// 1. When the user clicks "Subscribe", create the session
// with the UTMs you captured from the URL.
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/welcome?cs={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/pricing`,
// Free-form key/value pairs Stripe stores on the session
// and replays back to your webhook untouched.
metadata: {
utm_source: utms.utm_source ?? '',
utm_medium: utms.utm_medium ?? '',
utm_campaign: utms.utm_campaign ?? '',
landing_page: utms.landing_page ?? '',
referrer: utms.referrer ?? '',
},
});
Then, on the server, the webhook handler reads the metadata back and writes the channel attribution row to your database:
// app/api/stripe/webhook/route.ts (Next.js example)
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')!;
const body = await req.text();
const event = stripe.webhooks.constructEvent(body, sig, SECRET);
if (event.type === 'checkout.session.completed') {
const s = event.data.object;
// metadata is exactly what we set at creation time
await db.attribution.create({
customerId: s.customer as string,
utmSource: s.metadata?.utm_source ?? null,
utmMedium: s.metadata?.utm_medium ?? null,
utmCampaign: s.metadata?.utm_campaign ?? null,
amountCents: s.amount_total ?? 0,
});
}
return new Response('ok', { status: 200 });
}
We hit this exact issue on our own pricing page — Stripe Checkout strips UTMs from the redirect URL by default[4]. The metadata-passthrough approach above is what fixed it. Every invoice.paid renewal that fires later[3] can then be joined back to that original attribution row by customer, which is how you build true channel-level LTV instead of one-shot first-payment attribution.