Stripe
A payment completes on Stripe, so your webhook reports it, not the page. This guide passes the visitor id through the checkout, then runs RoutingCat’s Stripe mapping, the one its Stripe connection runs, in your webhook: it turns each of Stripe’s events into the outcomes it makes, keyed by the payment, so that each counts once whichever events report it. It builds on Track conversions.
Pass the visitor id at checkout creation
A webhook carries no visitor cookie, so the checkout carries the visitor id instead. Where your server creates the Checkout Session, read the rc_vid cookie and put its value in the metadata of the session and of what it creates. The example app’s pricing page does it in a Server Function:
'use server'
import { cookies, headers } from 'next/headers'import { redirect } from 'next/navigation'import Stripe from 'stripe'import { currencyFor, MONTHLY_PRICE } from './price'
/** * The checkout's Server Function: a Stripe Checkout session for the first month of the plan, in the * visitor's currency, carrying RoutingCat's visitor id so the payment's webhook can name the visitor. */export async function startCheckout(): Promise<never> { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '') const h = await headers() const origin = `${h.get('x-forwarded-proto') ?? 'https'}://${h.get('host')}` const visitorId = (await cookies()).get('rc_vid')?.value // On the session and on its payment, so whichever object the webhook reads names the visitor. const metadata: Stripe.MetadataParam = visitorId ? { rc_vid: visitorId } : {} const session = await stripe.checkout.sessions.create({ mode: 'payment', line_items: [ { quantity: 1, price_data: { currency: currencyFor(h.get('x-vercel-ip-country')).toLowerCase(), unit_amount: MONTHLY_PRICE * 100, product_data: { name: 'RoutingCat example plan, first month' }, }, }, ], metadata, payment_intent_data: { metadata }, // rc_return=stripe: coming back from the checkout keeps the visit's source. success_url: `${origin}/pricing?checkout=done&rc_return=stripe`, cancel_url: `${origin}/pricing`, }) redirect(session.url ?? '/pricing')}metadata, on the session and on what it creates:payment_intent_data.metadatainpaymentmode, as here, orsubscription_data.metadatainsubscriptionmode. Each event names a different object, and each then carries the visitor.rc_return=stripeonsuccess_url. The buyer’s return from Stripe’s checkout is then a return, not a new arrival, sosourcesstill credits the purchase to where the visit came from.- Where the buyer is signed in, put their account id in the same
metadata, asaccount_id, and give the mapping, below, a link that reads it:{ ...DEFAULT_LINK, account: [{ kind: 'metadata', key: 'account_id' }] }. The purchase then joins through the account too, as conversions do. Without one, the mapping names Stripe’s customer, asstripe:<customer id>.
Map Stripe’s events in your webhook
Your webhook hands each event to RoutingCat’s Stripe mapping, from routingcat/outcomes, which reads what it needs from your Stripe account and returns the outcomes the event makes, and the visitors it links to accounts. The example app’s webhook:
import { routingcat } from 'routingcat/next'import { DEFAULT_LINK, type Need, run, STRIPE_API_VERSION, stripe, stripeReadPath, stripeTrigger,} from 'routingcat/outcomes'import Stripe from 'stripe'
/** * Stripe's webhooks, signed with STRIPE_WEBHOOK_SECRET. RoutingCat's Stripe mapping, the one its * Stripe connection runs, turns each event into its outcomes, keyed by the payment whichever events * report it: a purchase, a renewal, a trial's start, a refund or a dispute naming the purchase it * corrects. The visitors they name are linked to their accounts, then each outcome is tracked. */export async function POST(request: Request) { const client = new Stripe(process.env.STRIPE_SECRET_KEY ?? '') let event: Stripe.Event try { event = client.webhooks.constructEvent( await request.text(), request.headers.get('stripe-signature') ?? '', process.env.STRIPE_WEBHOOK_SECRET ?? '', ) } catch { return new Response('Webhook signature verification failed', { status: 400 }) } // The visitor id in Stripe's objects' `metadata.rc_vid`; this site has no accounts of its own. const link = DEFAULT_LINK // The objects the mapping reads, from this app's Stripe account at the version each read names, // the event's own, whatever stripe-node pins: an object, or a page of a list. const read = async (need: Need) => { if (need.kind !== 'get' && need.kind !== 'list') return null const answer = await client.rawRequest('GET', stripeReadPath(need), undefined, { apiVersion: need.apiVersion, }) return need.kind === 'list' ? answer.data : answer } const { id: providerAccount } = await client.accounts.retrieveCurrent() const { mapped } = await run(stripe, stripeTrigger(event, link).object, read, link, { providerAccount, }) // An endpoint at an API version the mapping does not read sends what it cannot read: say which. const unread = mapped.dropped.find((d) => d.reason === 'unsupported_version') if (unread) { console.warn( `Stripe sent ${event.type} at API version ${unread.detail}, which RoutingCat does not read: set this webhook endpoint to ${STRIPE_API_VERSION}`, ) } for (const { accountId, visitorId } of mapped.links) { await routingcat.identify(accountId, { visitorId }) } for (const o of mapped.outcomes) { routingcat.track(o.name, { id: o.key, occurredAt: o.occurredAt, visitorId: o.visitorId, accountId: o.accountId, value: o.value, currency: o.currency, items: o.items?.map((i) => ({ id: i.id, quantity: i.quantity, unitValue: i.unit_value })), properties: { ...o.properties }, }) } return new Response('ok')}- The endpoint’s API version is the one thing to get right. In Stripe’s Dashboard, the webhook endpoint takes
routingcat/outcomes’STRIPE_API_VERSION, today2026-09-30.endive, the newest of the versions RoutingCat reads,STRIPE_API_VERSIONS. Every later 1.x release keeps reading each version a release told you to set, so an upgrade never stops your endpoint’s events from counting. An endpoint on a version RoutingCat does not read tracks nothing: the route logs that Stripe sent the event at that version, and which one to set, and still answers Stripe. Your app’s ownstripepackage can pin any version, since each read the mapping asks for names its own. - The endpoint’s events:
payment_intent.succeeded,invoice.paid,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,refund.created,refund.updated,refund.failed, andcharge.dispute.created,charge.dispute.updated,charge.dispute.closed,charge.dispute.funds_withdrawnandcharge.dispute.funds_reinstated. Neverinvoice.created, whose failed deliveries hold up your invoicing for up to 72 hours, norcheckout.session.completed, which makes Checkout wait before it sends the buyer back. readanswers the mapping’s reads from your Stripe account, throughstripeReadPathand at each read’sapiVersion, the version Stripe sent the event at: agetwith the object, alistwith the list’sdata. Line items, invoices and subscriptions are read from Stripe, never assumed from the event.- The keys. Each outcome’s
key, tracked as itsid, is the payment’s, asstripe:in:<invoice>orstripe:pi:<payment intent>. Stripe delivers some events twice, and out of order, and reports one payment through several events: the key makes each payment one event, whichever events reach you. - Values are in major units, with tax and shipping left out, and each outcome’s time is when the buyer acted, so a late delivery still counts when it happened.
- Links first:
identifylinks each visitor the mapping found to its account, thentracksends each outcome. - The signature. The handler checks Stripe’s signature before it trusts the event.
- Sandbox payments: point a Stripe sandbox’s webhook endpoint at a preview deployment, and your live endpoint at production. The recipe tracks whatever reaches the deployment, so a sandbox’s payments sent to production would count as real.
RoutingCat’s part of the proxy never runs on /api routes, so Stripe’s request reaches your handler unchanged.
Coming from a handler that tracks Stripe’s events itself, as on checkout.session.completed with Stripe’s event id: replace its tracking with the mapping in one deploy, and never run both. The old handler keyed each purchase by an event, the mapping keys it by the payment, so both together would count each payment twice. Add the new events to the endpoint, and set its API version, in the same change.
What the mapping tracks
| Outcome | When |
|---|---|
purchase |
A one-time payment, or a subscription’s first paid invoice above zero, after a trial included, marked after_trial |
renewal |
Each later paid invoice of a subscription, an upgrade’s proration included |
trial_started |
A subscription that starts with a trial |
cancel_scheduled, cancel_effective |
A subscription set to end, and one that has ended |
refund |
A refund that succeeded, as a negative value, or reversed |
dispute |
A dispute’s opening, its funds withdrawn or reinstated, and its end |
Declare in outcomes the ones you want reported, purchase at least. Refunds and disputes need no declaring: they correct a purchase, and results link each to the purchase it names, as counts and signed values per currency, with revenue net of refunds.
Pass the visitor to a Payment Link
A Payment Link or a pricing table takes no metadata from its address, but it takes a client_reference_id. Append ?client_reference_id= and the visitor id, the rc_vid cookie’s value, to the link, where you haven’t set one already; the id fits Stripe’s rule for that field. The mapping reads the visitor from it.
Choose what counts as the conversion
The objective counts within its window: 24 hours after the visitor first saw the page, by default. A payment at a trial’s end, or through a payment method that settles days later, comes days or weeks after the visit: the default 24-hour window misses it, and a longer window, up to the visit’s length, delays learning by as much. So a SaaS with trials makes sign-up or trial start its objective, and tracks purchase beside it as an outcome: counted and reported with its revenue, without steering routing.