How to record which marketing channel brought each Stripe customer
23 September 2026 · by Nordva, who make Nordva Launch
How do I record which marketing channel brought each customer into Stripe?
Remember the first visit's source in the browser, send it when the visitor clicks buy, and put it on the Checkout Session: your user id in client_reference_id, the source in subscription_data.metadata so it lands on the subscription. Session metadata alone stays on the session. A checkout.session.completed webhook then stores it in your database, and you can copy it onto the customer.
Stripe knows who paid and how much. It does not know that this person first arrived from a Reddit thread three weeks ago. Only your site saw that, so your site has to hand it over at the moment of purchase.
The guide on which channel brings paying customers explains the method: record the first touch, match it to money, group by source. It matches signups to Stripe by email after the fact. This guide is the other route, for products where people buy through Stripe Checkout: put the source on the Stripe objects themselves, so every subscription carries its own channel.
Where the source can live in Stripe
A Checkout Session gives you several places to put data, and they end up on different objects. That is the part most integrations get wrong.
| Field on session create | Where it ends up | Use it for |
|---|---|---|
client_reference_id | The Checkout Session only | Your own user id, up to 200 characters |
metadata | The Checkout Session only | A copy of the source, read in the webhook |
subscription_data.metadata | The subscription Checkout creates | The source, for subscriptions |
payment_intent_data.metadata | The PaymentIntent, then its charge | The source, for one-time payments |
Stripe’s metadata docs say it plainly: an object’s metadata is not copied to related objects automatically, and subscription_data.metadata is how a Checkout Session sets metadata on the underlying subscription. The same docs note that a PaymentIntent’s metadata is copied once to the charge it creates. Session metadata still matters, because the checkout.session.completed event carries it to your webhook.
The limits, from the same page: up to 50 keys, keys up to 40 characters, values up to 500. Never put card or bank details in metadata. A referring hostname and UTM values are fine.
The session create reference describes client_reference_id as a string to reconcile the session with your own systems, such as a customer or cart id, with a maximum of 200 characters. In subscription mode, Checkout creates a new Customer unless you pass an existing one.
Step 1: remember the first touch in the browser
With a waitlist form, you can read the source when the form loads, because the signup happens on the page the person landed on. A purchase is different: people land on a blog post, leave, come back through a search a week later and buy on your pricing page. By then the original referrer is gone. So store the first touch once, and never overwrite it.
<script>
// Record where this browser first came from. Runs on every page; writes only once.
(() => {
try {
if (localStorage.getItem("first_touch")) return;
const source = { landing_path: location.pathname, first_seen: new Date().toISOString() };
if (document.referrer) {
const host = new URL(document.referrer).hostname.replace(/^www\./, "");
if (host !== location.hostname.replace(/^www\./, "")) source.referrer = host; // your own pages are not a source
}
const q = new URLSearchParams(location.search);
for (const key of ["utm_source", "utm_medium", "utm_campaign"]) {
if (q.get(key)) source[key] = q.get(key).slice(0, 200);
}
localStorage.setItem("first_touch", JSON.stringify(source));
} catch {}
})();
// Call this from your Buy button.
async function buy() {
let source = {};
try { source = JSON.parse(localStorage.getItem("first_touch") || "{}"); } catch {}
const res = await fetch("/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ source }),
});
location.href = (await res.json()).url;
}
</script>
<button onclick="buy()">Subscribe</button>
Keep only the hostname of the referrer: a full referring URL can carry search terms and tokens, and a report needs none of them. Keeping the first value also protects you from a common mistake: after paying, the buyer returns from checkout.stripe.com, and a script that overwrote the source would now credit Stripe for the sale. The answer on checkout pages showing as a traffic source covers that in more detail.
Local storage is storage on the visitor’s device. Whether your site needs consent for it depends on where you and your visitors are; that is a legal question, not a technical one.
Step 2: create the Checkout Session with the source
The server takes the source from the request, keeps only the keys it expects, and passes it to Checkout twice: once as session metadata, which the webhook reads, and once as subscription_data.metadata, which stays on the subscription for as long as it exists. This is a whole working server with Express 5 (which passes errors from async handlers on for you); the webhook in step 3 is part of the same file.
npm install express stripe
STRIPE_KEY=rk_test_... STRIPE_WEBHOOK_SECRET=whsec_... PRICE_ID=price_... node server.mjs
// server.mjs: Stripe Checkout that carries each buyer's first-touch source into Stripe.
import express from "express";
import Stripe from "stripe";
import { appendFileSync } from "node:fs";
const stripe = new Stripe(process.env.STRIPE_KEY);
const SITE = process.env.SITE_URL ?? "http://localhost:4242";
const FIELDS = ["referrer", "utm_source", "utm_medium", "utm_campaign", "landing_path", "first_seen"];
const app = express();
app.use(express.static("public")); // public/index.html holds the page and the script above
// Known keys only, strings only, at most 500 characters (Stripe's limit for a metadata value).
function cleanSource(input) {
const source = {};
for (const key of FIELDS) {
const value = input?.[key];
if (typeof value === "string" && value.trim()) source[key] = value.trim().slice(0, 500);
}
return source;
}
// Your signed-in user's id, from your own session. Never take it from the request body.
function currentUserId(req) {
return undefined;
}
app.post("/checkout", express.json(), async (req, res) => {
const source = cleanSource(req.body?.source);
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: process.env.PRICE_ID, quantity: 1 }],
success_url: `${SITE}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${SITE}/`,
client_reference_id: currentUserId(req),
metadata: source, // stays on the Checkout Session
subscription_data: { metadata: source }, // copied onto the subscription Checkout creates
});
res.json({ url: session.url });
});
app.get("/thanks", (req, res) => res.send("Thank you. Your subscription is active."));
const handled = new Set(); // in production, a table of event ids
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.get("stripe-signature"), process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Signature check failed: ${err.message}`);
}
res.json({ received: true }); // answer first, work after
if (event.type !== "checkout.session.completed" || handled.has(event.id)) return;
handled.add(event.id);
const session = event.data.object;
const row = {
session: session.id,
user: session.client_reference_id,
customer: session.customer,
subscription: session.subscription,
email: session.customer_details?.email,
...session.metadata,
};
appendFileSync("purchases.jsonl", JSON.stringify(row) + "\n"); // your database here
// Optional: put the source on the customer as well, keeping the first one it ever had.
try {
if (!session.customer || !Object.keys(session.metadata ?? {}).length) return;
const customer = await stripe.customers.retrieve(session.customer);
if (customer.deleted || FIELDS.some((key) => customer.metadata[key])) return;
await stripe.customers.update(session.customer, { metadata: session.metadata });
} catch (err) {
console.error("Could not copy the source to the customer:", err.message);
}
});
app.listen(4242, () => console.log(`Listening on ${SITE}`));
Use a restricted key with write access to Checkout Sessions and Customers and nothing else; if a permission is missing, Stripe’s error says which. The /checkout route never trusts a user id from the browser: fill in currentUserId from your own login session, or leave it empty for buyers who have no account yet.
Step 3: read it back in the webhook
The /webhook route above is the part that turns a redirect into a record. Stripe’s fulfilment guide is clear that you cannot rely on the success page alone, because a buyer can pay and then lose their connection before it loads; listening for the webhook is what makes sure every payment is handled.
What the handler does, and why, following Stripe’s webhook docs:
- Checks the signature against the raw body.
express.rawkeeps the body exactly as Stripe sent it; parsing it as JSON first makes the check fail. - Answers 2xx before doing any work. Stripe retries failed deliveries with backoff for up to three days in live mode.
- Skips events it has already handled. The same event can arrive more than once; keep the event ids you processed. Events can also arrive in any order.
- Stores the source with your own ids. The row joins your user, the Stripe customer and subscription, and the source. That row is what your reports read.
- Optionally copies the source onto the customer, only if the customer has none yet, so a second purchase years later does not rewrite where they first came from. Checkout creates the customer, so this has to happen after the session completes. If you would rather keep the key read-only here, create the customer yourself before Checkout with the source in its metadata, and pass its id as
customer.
To try it locally, forward events with the Stripe CLI, as the fulfilment guide shows, and pay with a test card:
stripe listen --forward-to localhost:4242/webhook
Step 4: count customers per source
Because the source now lives on each subscription, the report is a loop over subscriptions with a read-only key. No email matching, no joins:
// source-report.mjs: paying subscriptions per first-touch source, read back from Stripe.
// Usage: STRIPE_KEY=rk_live_... node source-report.mjs
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_KEY);
const counts = new Map();
for await (const sub of stripe.subscriptions.list({ status: "all", limit: 100 })) {
if (!["active", "past_due"].includes(sub.status)) continue; // paying now
const source = sub.metadata.utm_source ?? sub.metadata.referrer ?? "(no source recorded)";
const key = `${source}\t${sub.currency}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
console.log("source\tcurrency\tpaying subscriptions");
for (const [key, n] of [...counts].sort((a, b) => b[1] - a[1])) console.log(`${key}\t${n}`);
For a single question, the Search API can filter subscriptions by metadata directly, for example metadata["utm_source"]:"reddit". Search data can lag by about a minute, so use it for reports, not inside the webhook.
One-time payments
For mode: "payment", swap subscription_data for payment_intent_data: { metadata: source }. The metadata lands on the PaymentIntent and is copied to its charge. Checkout in payment mode only creates a Customer when it has to, so pass customer_creation: "always" if you want one to attach the source to.
What this does not catch
- Another device or a cleared browser. The first touch lives in one browser’s storage. Someone who found you on their phone and paid on their laptop shows the laptop’s first visit, or none.
- Payments that skip your server. A Payment Link shared directly, or a subscription you create by hand in the Dashboard, never passes through
/checkout, so it carries no source. - Subscriptions from before you added this. They show as “(no source recorded)”. For those, the email-matching method in the channel guide still works.
- Everything after the first touch. This records where someone first came from, not everything that persuaded them.
Who should not use Launch for this
If you already store the source on your own user table and your reports read from there, the code above may be all you need. If you want a multi-touch model that splits credit across every visit, Launch records first touch only. And if you sell through anything other than Stripe, Launch cannot read the payments.
The faster way: Nordva Launch
Launch does the reading and the report, so the only line you add to Checkout is one metadata key. It traces a paying customer to their first visit in this order: nordva_visitor_id in the customer’s or subscription’s metadata, identify() with the user id, identify() with the email, then the email on your waitlist. With Checkout, that means passing subscription_data: { metadata: { nordva_visitor_id } }; the web analytics guide shows where the visitor id comes from.
You connect Stripe in the dashboard with a restricted key, and Launch only sends GET requests; every plan can connect. Paying customers by referring site and by country work on every plan with Stripe connected; grouping by UTM source and campaign needs Builder. The Attribution API page shows the report. Start free: no time limit, no card.
Questions
- Does Stripe record where a customer came from?
- No. Stripe records the payment, the customer and whatever you put in metadata. The referring site and UTM values of the visit that brought the person are only known to your site, so you have to pass them in when you create the Checkout Session.
- Why is my Checkout Session metadata not on the subscription?
- Stripe does not copy metadata between related objects automatically. Session metadata stays on the session. To set metadata on the subscription Checkout creates, pass it as subscription_data.metadata; for a one-time payment, payment_intent_data.metadata sets it on the PaymentIntent.
- What should go in client_reference_id?
- Your own id for the buyer, such as the user id from your session, so the payment can be matched to the account in your database. It holds up to 200 characters and is only on the Checkout Session, not on the customer or subscription.
- Can I put the source on the Stripe customer?
- Yes, two ways. Create the customer yourself before Checkout with the source in its metadata and pass its id as customer, or update the customer's metadata from your webhook after Checkout has created it. Both need a key that can write customers.
Try it in your project
npx nordva@latest init
Free plan, no card, no invite.