How to track Stripe revenue in your analytics
How do I get my Stripe revenue to show up next to my traffic?
Join Stripe to your analytics by stamping the analytics visitor id onto the checkout session you create, then reading it back off the webhook when the charge succeeds. That one identifier is what lets a payment be credited to a source; without it, Stripe revenue and site traffic stay two unrelated datasets.
Why Stripe and your analytics don’t line up
A Stripe charge knows the amount, the currency, the customer’s email and the time. Your analytics knows the session: the referrer, the UTM parameters, the landing page, the device, and a visitor id kept in a first-party cookie. Nothing in the charge references a browser, and nothing in the session survives into Stripe. The two records describe the same human being and share no field.
That is why a revenue dashboard cannot tell you which campaign brought the money in, and why a traffic dashboard ranks channels by visitors — the only number it has. Joining them is not a reporting problem you fix later with a query. It is a plumbing problem, solved at checkout, before the money moves.
Stamp the visitor id onto the Checkout Session
Stripe Checkout has a field for exactly this: client_reference_id. It is free-text, stored on the session and handed straight back to you on the webhook. Put your analytics visitor id in it.
Read that id server-side, off the cookie on the request that creates the session. Statlark’s tracker sets _slk_vid, and statlark.getVisitorId() returns the same value client-side. Stamp it in two places: client_reference_id is often already spoken for by your own order id, and metadata gives you somewhere else to put it.
// Read the analytics visitor id server-side, from the cookie on the request
// that creates the Checkout Session. Statlark's tracker sets `_slk_vid`.
function visitorIdFrom(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
for (const part of cookieHeader.split(";")) {
const [key, ...rest] = part.trim().split("=");
if (key === "_slk_vid") return decodeURIComponent(rest.join("="));
}
return null;
}
export async function POST(req: Request) {
const visitorId = visitorIdFrom(req.headers.get("cookie"));
const customerEmail = await currentUserEmail(req); // may be null
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: "price_1QxSomething", quantity: 1 }],
success_url: "https://example.com/thanks",
cancel_url: "https://example.com/pricing",
// The primary join key for a one-time session: Stripe stores it and hands
// it straight back on the webhook. A subscription is billed from an invoice,
// so there it rides on subscription_data.metadata below instead.
client_reference_id: visitorId ?? undefined,
// Fallback slot, for when client_reference_id already carries your order id.
metadata: visitorId ? { statlark_visitor_id: visitorId } : undefined,
// Feeds the email fallback when the id is missing entirely.
customer_email: customerEmail ?? undefined,
// Subscriptions only — and the line people forget. Copied onto every future
// renewal invoice, which is the only thread back to the original visitor.
subscription_data: {
metadata: visitorId ? { statlark_visitor_id: visitorId } : {},
},
});
return Response.json({ url: session.url });
}The subscription_data.metadata line is the one people miss. A renewal invoice twelve months from now has no checkout session, no browser and no cookie; the metadata copied onto the subscription is the only thread back to the visitor who signed up. Leave it out and nothing on that subscription carries the id — not even month one, since a subscription is billed from an invoice rather than from the session — which quietly makes your best acquisition channels look worse than they are.
Read it back off the webhook
Point a webhook endpoint at your server and verify every event against the raw request body. This is the half of the job that decides whether the numbers are trustworthy, so read the whole handler, not the happy path.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
// Verify against the RAW body. Parsing the JSON and re-serialising it changes
// the bytes, and the signature check fails for reasons that look unrelated.
const raw = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
raw,
req.headers.get("stripe-signature")!,
endpointSecret,
);
} catch {
return new Response("invalid signature", { status: 400 });
}
// Test-mode events must never reach the same numbers as live ones.
if (!event.livemode) return Response.json({ ignored: "test_mode" });
switch (event.type) {
case "checkout.session.completed": {
const s = event.data.object;
// A subscription checkout is billed by invoice.paid. Recording it here
// as well counts the first month twice.
if (s.mode === "subscription") break;
if (s.payment_status !== "paid" || !s.amount_total) break;
await recordPayment({
// Key on the money, not the delivery: Stripe can send the same event
// more than once, and each retry is a separate delivery.
transactionId:
typeof s.payment_intent === "string" ? s.payment_intent : s.id,
amount: toMajorUnits(s.amount_total, s.currency ?? "usd"),
currency: s.currency ?? "usd",
visitorId:
s.client_reference_id ?? s.metadata?.statlark_visitor_id ?? null,
email: s.customer_details?.email ?? s.customer_email ?? null,
occurredAt: new Date(event.created * 1000),
});
break;
}
case "invoice.paid": {
const inv = event.data.object;
if (!inv.amount_paid) break; // a $0 invoice is a trial, not revenue
await recordPayment({
// payment_intent is not a top-level string on every API version, so
// fall back to the invoice id — and key refunds the same way.
transactionId:
typeof inv.payment_intent === "string" ? inv.payment_intent : inv.id,
amount: toMajorUnits(inv.amount_paid, inv.currency),
currency: inv.currency,
// Stamped by subscription_data.metadata above. Survives every renewal.
visitorId:
inv.subscription_details?.metadata?.statlark_visitor_id ?? null,
email: inv.customer_email,
occurredAt: new Date(event.created * 1000),
});
break;
}
case "charge.refunded": {
const c = event.data.object;
// amount_refunded is CUMULATIVE for the charge, so SET it — never add.
// That alone makes a re-delivered refund event a no-op.
await recordRefund({
transactionId:
typeof c.payment_intent === "string" ? c.payment_intent : c.id,
refundedAmount: toMajorUnits(c.amount_refunded, c.currency),
});
break;
}
}
// Anything that isn't a 2xx gets retried. Acknowledge fast, then do slow work.
return Response.json({ received: true });
}When there is no visitor id
Some payments arrive with nothing attached: an invoice you sent by hand, a renewal from before you added the metadata, a buyer who cleared cookies between browsing and paying. The fallback is to match Stripe’s customer email against an email your analytics already holds for a visitor — which means you have to be telling it, with statlark.identify({ user_email }) at sign-in or the equivalent in whatever tool you use.
Normalise before comparing. Stripe stores what the customer typed, your app may hold a different casing or a stray space, and a match that fails on whitespace is indistinguishable from a customer you never saw. Statlark trims and lower-cases both sides before looking.
The failure mode deserves naming, because it is common: a customer who pays with a different email than they browsed with is unrecoverable. Someone signs in as their personal address, then checks out with the company card and the company address. No signal links the two, and no matching logic invents one. The payment still counts toward your revenue; it just counts without a source. That is the ceiling on email matching, and the whole argument for stamping the id at checkout. Where more than one visitor has identified with the same address, Statlark takes the most recently seen one: deterministic, and occasionally wrong.
The events worth handling
checkout.session.completed is where everyone starts and it is not enough on its own. A subscription business that records only the first charge under-reports most of its income, and gross revenue that only ever goes up is a lie.
| Event | Fires when | Why you need it |
|---|---|---|
checkout.session.completed | A Checkout Session finishes | One-time purchases. Check payment_status — the event also fires for sessions that were never paid. |
invoice.paid | A subscription invoice is paid | The first subscription charge and every renewal. billing_reason tells them apart: subscription_create vs subscription_cycle. |
charge.refunded | A refund is issued, fully or partly | Carries amount_refunded — the cumulative total refunded on that charge, not the delta. |
charge.dispute.created | A customer files a chargeback | The money is held and may be lost. Not final either way. |
charge.dispute.closed | The dispute resolves | The only point at which you know whether the money is gone or won back. |
Two traps live in that table. First, a subscription checkout emits checkout.session.completed too — the money arrives on invoice.paid, so handling both without checking the session mode counts the first month twice. Second, disputes are not refunds: a refund only moves one way, while a dispute can be won back, so a store that can only ever subtract cannot represent one honestly. Statlark takes the narrow option — its refund path is deliberately monotonic, and its built-in webhook subscribes checkout.session.completed, invoice.paid and charge.refunded only. To count disputes, handle charge.dispute.closed yourself and post the outcome through the REST API.
Idempotency, or you will double-count
Stripe retries any delivery that does not return a 2xx, and can send the same event more than once even when you did. A handler that inserts a row per delivery turns one flaky deploy into a permanent overstatement that nobody notices until a month-end total disagrees with Stripe.
Key every write on a stable identifier for the money — the payment intent id, or the invoice id for a subscription — never the event id, and make the insert a no-op when that key already exists. Statlark stores payments unique on (site, transaction id) with an on conflict do nothing, and its recording function returns true only when a row was actually inserted.
That return value matters more than it looks. Anything you do because a payment landed — send a receipt, emit a conversion, increment a counter — has to be gated on the insert really happening, or your writes are idempotent and your side effects are not. Statlark gates its automatic payment, subscription_started and subscription_renewedgoals on exactly that flag. And acknowledge fast: do the slow work after you have returned 200, or a handler that outlives Stripe’s delivery timeout gets retried while it is still running.
Amounts are integers, and JPY has no cents
Stripe sends money as an integer in the currency’s smallest unit. amount_total: 4999 is $49.99, so dividing by 100 is right — for most currencies. Sixteen have no minor unit at all: a 5,000 yen sale arrives as 5000, and dividing by 100 records 50 yen. You under-report by a hundred times, silently, in exactly the market you were least likely to be checking. (Stripe also documents three-decimal currencies — BHD, JOD, KWD, OMR, TND — where the minor unit is a thousandth.)
// Stripe sends an integer in the currency's smallest unit. Most currencies have
// two decimals, so 4999 -> 49.99. These sixteen have no minor unit at all:
// a 5,000 yen sale arrives as 5000, and dividing by 100 records 50 yen.
const ZERO_DECIMAL = new Set([
"bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga",
"pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf",
]);
function toMajorUnits(minor: number, currency: string): number {
return ZERO_DECIMAL.has(currency.toLowerCase()) ? minor : minor / 100;
}Then decide what a multi-currency store does with the rest, because adding raw numbers across currencies produces a total that means nothing. Statlark makes the conservative choice: each site has one configured currency, and a payment in any other is recorded but left out of the revenue rollups rather than converted at a rate the report cannot show you. Fewer sales counted, no invented numbers — the trade-off, and the alternative, are in mixed currencies.
Keep test mode out of your numbers
Test and live are separate worlds in Stripe: separate objects, separate webhook endpoints, separate signing secrets. Every event carries livemode, and dropping the ones where it is false is a one-line guard that saves a tedious cleanup — a test purchase is indistinguishable from a real one once it is in the table.
Statlark holds one Stripe signing secret per site, so a test endpoint and a live endpoint cannot both be connected to the same site — registering a second one replaces the first. Point test mode at a separate site, or do not connect it at all. If something does slip through, DELETE /api/v1/paymentswith the transaction id removes that payment and recomputes the affected customer’s lifetime value.
What the join actually buys you
Once payments carry a visitor id, each one inherits everything already recorded about that visitor — including the source, landing page and campaign frozen at their first touch. In Statlark that means traffic sources ranked by revenue instead of by visitor count, each carrying its own revenue per visitor, and refunds netted out of every figure they touch: the site total, each source, each landing page and each person’s lifetime value. It also means no Stripe API key ever changes hands — the webhook authenticates by verifying Stripe’s signature, and there is correspondingly nothing to backfill from, so connect it before the campaign you want to measure.
One consequence of netting worth knowing up front: a refund is credited against the original sale— same visitor, same source, same date — rather than posted on the day it was issued. That keeps a channel’s revenue honest, since money it brought in and later gave back nets to what it truly earned. It also means a refund of an old sale will not appear in a report window that excludes the original purchase date.
The setup walkthrough is in Revenue attribution, and the REST API covers pushing payments, refunds and identity from your own server if you are not on Stripe at all. The two questions the join exists to answer are which marketing channel actually made the money and what a customer is worth over time.
Last reviewed