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.

server
const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: "price_123", quantity: 1 }],
  success_url: "https://example.com/thanks",
  metadata: {
    numberhill_visitor_id: visitorId, // from the browser, see below
  },
  // Subscriptions only, and easy to miss: session metadata is NOT copied
  // onto renewal invoices. Without this, month 2 onward arrives with no
  // visitor id and lands in "Unattributed" — leaving the channel that won
  // the customer credited with a single month of their lifetime value.
  subscription_data: {
    metadata: { numberhill_visitor_id: visitorId },
  },
});

The browser half — pass the ID to your server when you kick off checkout:

browser
await fetch("/api/checkout", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    priceId,
    visitorId: window.numberhill?.visitorId() ?? null,
  }),
});

LemonSqueezy

Pass it as custom data on the checkout — it arrives back under meta.custom_data.

ts
const checkout = await createCheckout(storeId, variantId, {
  checkoutData: {
    custom: { numberhill_visitor_id: window.numberhill.visitorId() },
  },
});

Polar

ts
await polar.checkouts.create({
  productPriceId: priceId,
  successUrl: "https://example.com/thanks",
  metadata: { numberhill_visitor_id: visitorId },
});

Shopify

Shopify has no metadata field on checkout, so the ID travels as a cart attribute and comes back in note_attributes on the orders/paid webhook. 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.

js
// Add as a cart attribute before checkout
await fetch("/cart/update.js", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    attributes: { numberhill_visitor_id: window.numberhill.visitorId() },
  }),
});

Dodo Payments

ts
await dodo.payments.create({
  product_id: productId,
  metadata: { numberhill_visitor_id: visitorId },
});

Things worth knowing

  • window.numberhill is only defined after the script loads. Use window.numberhill?.visitorId() ?? null so a blocked script never breaks checkout.
  • datafast_visitor_id is accepted as an alias everywhere, so a migration doesn't need a coordinated deploy.
  • 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.