Start here
Connect checkout
This is the step that turns analytics into attribution. When a visitor starts checkout, read their ID with window.numberhill.visitorId() and put it in your processor's metadata. The webhook later carries it back, and NumberHill matches the payment to the visit.
Skip this and revenue lands in “Unattributed”
Without the visitor ID in metadata there is nothing to join on. Payments still record, but no channel gets credit.Stripe
Set it on the Checkout Session or the PaymentIntent — whichever you create.
// wherever the user starts checkout
await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// optional chaining: a blocked or slow script must never break checkout
visitorId: window.numberhill?.visitorId() ?? null,
}),
});const session = await stripe.checkout.sessions.create({
// ...line items, prices...
metadata: { numberhill_visitor_id: visitorId },
// Subscriptions: session metadata is NOT copied onto renewal invoices,
// so without this every renewal after the first lands "Unattributed".
subscription_data: {
metadata: { numberhill_visitor_id: visitorId },
},
});LemonSqueezy
Pass it as custom data on the checkout — it arrives back under meta.custom_data.
// wherever the user starts checkout
await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// optional chaining: a blocked or slow script must never break checkout
visitorId: window.numberhill?.visitorId() ?? null,
}),
});const checkout = await createCheckout(storeId, variantId, {
checkoutData: { custom: { numberhill_visitor_id: visitorId } },
});Polar
// wherever the user starts checkout
await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// optional chaining: a blocked or slow script must never break checkout
visitorId: window.numberhill?.visitorId() ?? null,
}),
});await polar.checkouts.create({
// ...products...
metadata: { numberhill_visitor_id: visitorId },
});Shopify
You never touch Shopify's checkout — the ID travels as a cart attribute set from the theme, and Shopify copies cart attributes onto the order, where the orders/paid webhook reads it from note_attributes. Paste this into theme.liquid, below the tracking snippet. Failing that, a numberhill_visitor_id in the query string of the first page the customer landed on is read back out of the order's landing_site — useful for campaign links on a store whose theme you can't edit.
<script>
// Shopify copies cart attributes onto the order; the webhook reads it there.
window.addEventListener("load", function () {
var id = window.numberhill && window.numberhill.visitorId();
if (!id) return; // tracker blocked — attribution falls back to the landing URL
fetch("/cart/update.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ attributes: { numberhill_visitor_id: id } }),
});
});
</script>Dodo Payments
// wherever the user starts checkout
await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// optional chaining: a blocked or slow script must never break checkout
visitorId: window.numberhill?.visitorId() ?? null,
}),
});await dodo.payments.create({
// ...product...
metadata: { numberhill_visitor_id: visitorId },
});Things worth knowing
window.numberhillis only defined after the script loads. Usewindow.numberhill?.visitorId() ?? nullso a blocked script never breaks checkout.- Server-side checkouts work the same way — just forward the ID from the browser to your backend first.
- Selling in a zero-decimal currency (JPY, KRW, VND…) needs nothing from you. Your processor quotes those without a minor unit and NumberHill normalizes them on the way in, so ¥5,000 is reported as ¥5,000.
Verify
Run a test payment. In the dashboard's Revenue view the transaction should appear with a channel name rather than Unattributed.