Revenue attribution
Connect your Polar or Stripe account to a Site and each payment becomes a Payment, attributed to the Visitor who made it. The Conversions page then shows Revenue, the Sources and countries it came from, and each customer's journey up to the Payment. Revenue also joins the dashboard's key figures, the Pages table and All sites.
We store the amount, the currency, the provider's payment and customer ids, and the Visitor id. It never stores customer emails: they are fetched from the provider when someone opens the Conversions page, and Viewers see them masked.
A Site connects one provider at a time. Switching to the other keeps the webhook URL but needs the webhook set up again at the new provider.
Polar
1. Create an access token
In Polar, open Settings → General → Developers and create an Organization Access Token with the
customers:read scope. Use the sandbox dashboard (sandbox.polar.sh) while you test, and production once you sell.
2. Connect it to the Site
In the Site's Settings → Attribution → Revenue attribution, choose Polar and the environment, paste the token and pick the currency you sell in (see Currency).
The token is checked against Polar before it is saved, and it is never shown again.
3. Add the webhook
The settings page then shows a webhook URL and a secret. In Polar, open Settings → Webhooks → Add Endpoint:
- URL: the webhook URL
- Format: Raw
- Secret: the secret
- Events:
order.paid
Polar signs every delivery with the secret, and deliveries without a valid signature are refused. Changing the token or the currency later keeps the same URL and secret; disconnecting and connecting again gives new ones.
4. Pass the Visitor id to the checkout
The tracking snippet gives each Visitor an id, sd.visitorId(). Send it with the request that starts the checkout,
then put it in the checkout's metadata as sd_visitor_id. Polar copies checkout metadata onto the order it creates.
In the browser:
async function buy(productId) {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ productId, visitorId: sd.visitorId() }),
});
location.href = (await response.json()).url;
}
On your server, with Polar's SDK:
import { Polar } from "@polar-sh/sdk";
const polar = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN, server: "sandbox" });
export async function createCheckout({ productId, visitorId }) {
const checkout = await polar.checkouts.create({
products: [productId],
metadata: visitorId ? { sd_visitor_id: visitorId } : {},
});
return { url: checkout.url };
}
Stripe
1. Create a restricted key
In Stripe, open Developers → API keys → Create restricted key and give it Read on Customers and
Checkout Sessions, nothing else. Create it in test mode (or a sandbox) while you test, and in live mode once you
sell. A secret key (sk_…) is refused: a restricted key can only read what Revenue attribution needs.
2. Connect it to the Site
In the Site's Settings → Attribution → Revenue attribution, choose Stripe, paste the key and pick the currency you
sell in (see Currency). The key's prefix tells test mode (rk_test_) from live (rk_live_).
The key is checked against Stripe before it is saved, and it is never shown again.
3. Add the webhook and paste its signing secret
The settings page then shows a webhook URL. In Stripe, in the same mode as the key, open Developers → Webhooks → Add destination:
- Events from: your account
- Events:
checkout.session.completed,checkout.session.async_payment_succeededandinvoice.paid - Destination: a webhook endpoint with the webhook URL
Stripe then shows the endpoint's signing secret (whsec_…). Paste it into the settings page and save it.
Deliveries are refused until it is saved, and whenever their signature does not match it. After rolling the secret in
Stripe, paste the new one. Changing the key or the currency keeps the URL and the signing secret, except that moving between test mode
and live clears the secret: add the endpoint in the other mode too and paste its secret.
4. Pass the Visitor id to the checkout
Stripe reads the Visitor id from the Checkout Session's client_reference_id, or from sd_visitor_id in its
metadata.
Stripe Checkout. Send sd.visitorId() from the browser with the request that starts the checkout (as in the Polar
example above), then on your server:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function createCheckout({ priceId, visitorId }) {
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: priceId, quantity: 1 }],
success_url: "https://example.com/thanks",
client_reference_id: visitorId ?? undefined,
});
return { url: session.url };
}
Subscriptions. The first payment is attributed through the Checkout Session. Renewals arrive as invoice.paid,
which carries the subscription's metadata, not the session's, so put the Visitor id there too:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: "https://example.com/thanks",
client_reference_id: visitorId ?? undefined,
subscription_data: { metadata: visitorId ? { sd_visitor_id: visitorId } : {} },
});
The first payment arrives both as the session and as its first invoice; it is counted once.
Payment Links. A Payment Link takes client_reference_id in its URL, so add the Visitor id to the link in the
browser:
for (const link of document.querySelectorAll("a[href^='https://buy.stripe.com/']")) {
link.addEventListener("click", () => {
const visitorId = sd.visitorId();
if (visitorId) {
const url = new URL(link.href);
url.searchParams.set("client_reference_id", visitorId);
link.href = url.toString();
}
});
}
A Payment Link cannot set subscription metadata, so a subscription bought through one has its first payment attributed and its renewals left out.
Currency
Revenue figures add up Payments in the connection's currency only. Payments in other currencies are listed on the Conversions page but left out of the totals, so changing the currency later changes which Payments count.
Payments that are not recorded
sd.visitorId() returns null until the first hit has been sent, so call it after the page has loaded, and start the
checkout without the Visitor id when it is null. Such payments, and payments whose Visitor id belongs to another
Site, are not recorded.
How a Payment is attributed
- Amount: after discounts and before tax: Polar's net amount, a Checkout Session's total less its tax, and an invoice's total excluding tax. A payment of nothing, such as a trial's, is not a Payment.
- Visitor: the Visitor id passed to the checkout. Visitor ids change every day (see the Privacy model), so a
customer who first visited on another day is linked to that day only when your Site calls
sd.identify()for them. - Source and country: those of the payer's first Visit.
- Time to convert: from that first Visit to the Payment.
- Filters: a Payment matches when its Visitor had a Visit matching every Filter.
- Pages: a page earns a Payment's amount once when the payer viewed it before paying.
- Refunds are not subtracted: only paid orders, sessions and invoices are read.
- Share links show Revenue totals (the key figure, chart and Pages column) but never the Conversions page, customers or single Payments.