Attribution
How payments are linked to visits (checkout reference, user id, email) and which visit gets the credit.
A payment provider does not know which visit a payment came from. PilotFox works it out in two steps:
- Linking finds the person who paid and the visit the payment belongs to: the visit that started the checkout, else that person's latest visit before paying.
- Crediting picks which of that person's visits the reports credit with the money: whose channel, referrer, campaign, landing page, country and device the revenue counts for. That is the site's credit model.
Affiliates have their own rule, which follows the browser that paid: see affiliates.
How a payment is linked
PilotFox tries these in order and stops at the first match. The payments list and the payments API report which one
matched as attribution.
| Order | attribution | Matches when | Linked visit |
|---|---|---|---|
| 1 | checkout_ref | A checkout reference the payment carries equals the ref of a checkout event sent from your pages in the 7 days before the payment. | The session of the earliest such event. |
| 1 | checkout_ref | Failing that, the payment's own id at the provider equals the ref of a checkout event sent from your return page up to an hour after the payment. | That person's latest session started before the payment. |
| 2 | user_id | A user id the payment carries is a user id you passed to identify. | That user's latest identified session before the payment. |
| 3 | email | The customer's email equals, ignoring case, the email trait your server sent for an identified user. | That user's latest identified session before the payment. |
| 4 | customer | The provider's customer (Stripe's customer id, Dodo's and Polar's customer, the payments API's customer_id) paid earlier on this site, through the same provider, and that payment is linked. | The same visit as the customer's latest earlier linked payment. |
| – | none | Nothing matched. | None: the payment still counts in totals. |
Subscriptions come first. Every payment of a subscription (renewals, upgrades) is linked like the subscription's earliest linked payment, normally its first, and credited to the same visit under every credit model, affiliate included. This takes precedence over the steps above, customer included: a renewal stays with the visit that started its subscription, even when it carries a reference of its own. Payments arrive in any order (an import reads the newest first), so when the first payment arrives after its renewals, the renewals follow it. This applies to synced providers; the payments API has no subscriptions.
Customers work both ways. A later payment of the same customer imported before the first one is linked once the first one arrives and is linked.
Option 1: a checkout reference
When the visitor starts checkout, create a fresh reference, send it with a checkout event, and pass the same
reference to the provider when your server creates the checkout.
async function startCheckout() {
const ref = crypto.randomUUID(); // letters, digits and dashes; needs HTTPS
window.pilotfox?.event("checkout", { ref }); // "?." so a blocked script never blocks a sale
const res = await fetch("/create-checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ref }),
});
location.href = (await res.json()).url;
}Your server then puts ref on the checkout as metadata.pilotfox_ref. Each provider's page has the code:
Stripe,
Dodo Payments,
Polar. On other billing systems, send it as the
payments API's ref.
Which fields are checkout references
| Source | Read as a checkout reference |
|---|---|
metadata.pilotfox_ref (Stripe Checkout Session or its payment intent; Stripe invoice or its subscription's metadata; Dodo payment; Polar order, copied from its checkout) | In any format |
Payments API ref | In any format |
Dodo Payments checkout_session_id of the payment | As is (send the session id as the event's ref) |
Stripe client_reference_id, Polar metadata.reference_id | Only in the PilotFox format (below); any other value is read as a user id |
A metadata value is used only when it is at most 500 characters.
References and your own ids
Stripe's client_reference_id and Polar's reference_id often hold the merchant's own id: a user, cart or order
id. Those are easy to guess, and anyone can send a checkout event carrying one from their own browser. So these two
fields count as a checkout reference only in the format the PilotFox script mints, and otherwise as a user id only,
never both:
-
A PilotFox reference is
pffollowed by the decimal digits of four random 32-bit numbers (128 bits). The script adds one to payment links (below); you can make one the same way:const ref = "pf" + crypto.getRandomValues(new Uint32Array(4)).join(""); // e.g. "pf2139954139356687339242114731773333166471" -
Anything else in those two fields (your user id, a UUID, an order number) is matched as a user id (option 2), never against
checkoutevents.
A reference your server makes in another format, such as crypto.randomUUID(), goes in metadata.pilotfox_ref.
The earliest event wins
A reference counts from the first checkout event that carried it: the payment is linked to the session of the
earliest matching event within the 7 days before the payment. A later checkout event repeating the reference, from
any browser, never moves the link or the affiliate. So make a fresh, unguessable reference for every checkout, and
send the event before the redirect to the provider (an event up to a minute after the provider's payment time still
counts, for clock differences).
The checkout's visit is also where crediting stops: visits the buyer made after starting the checkout are not credited with that sale.
Option 2: the user id
If you already call identify when people sign in, put the same id in the checkout's metadata as
pilotfox_user_id:
// In the page, after sign-in:
window.pilotfox?.identify(user.id);
// On your server, when creating the checkout:
metadata: { pilotfox_user_id: user.id },Where PilotFox reads a user id, in order:
| Provider | User id fields |
|---|---|
| Stripe | metadata.pilotfox_user_id (session, then payment intent; on invoices, the invoice then its subscription), then client_reference_id when it is not a PilotFox reference |
| Dodo Payments | The payment's metadata.pilotfox_user_id, then its customer's metadata.pilotfox_user_id |
| Polar | metadata.pilotfox_user_id, the customer's external_id (the checkout's external_customer_id), then metadata.reference_id when it is not a PilotFox reference |
| Payments API | user_id, then customer_id |
Only sessions of identified users count for this step. Use ids that cannot be guessed, such as random ids, rather than sequential numbers or email addresses: see what a visitor can claim.
Option 3: the email (fallback)
Send each signed-in user's email from your server, with the server-side identify and an API
key that has the ingest:write permission, for example when they sign in (the IP address and user agent of that
request name their browser):
curl -X POST "https://pilotfox.app/api/v1/ingest/identify" \
-H "Authorization: Bearer $PILOTFOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"site": "SITE_ID", "uid": "usr_8f3k2", "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", "traits": {"email": "buyer@example.com"}}'A payment whose customer email matches that address, ignoring case, is then linked to that user's latest identified visit before the payment.
Only an email your server sends links payments
An email trait set in the browser (pilotfox.identify(id, { email })) still shows in the Users report, but any
visitor can set one for any user id, so payments ignore it. Editing a user's traits in the dashboard never makes an
email link payments either, and removing or changing the email there stops payments linking by the old address until
your server sends one again. After you start sending emails from your server, use Match again so payments
imported before are linked too.
Use email as a fallback: references and user ids are exact.
What a visitor can claim
The script runs in your visitors' browsers, so anyone can send a checkout event, call identify with any user id or
set any trait from their own browser. None of that alone moves money:
- A checkout reference counts from the first event that carried it, and a Stripe
client_reference_idor Polarreference_idcounts as one only in the PilotFox format. - Only an email sent by your server links a payment.
- An affiliate is credited only from the affiliate links of the browser the payment is linked to (affiliates).
- The
refvalues ofcheckoutevents are shown only to people and API keys withsessions:read. Without it (for example on a share link without sessions, or a public dashboard),GET /events/properties?name=checkoutleaves therefproperty out, and aprop:reffilter on any report is refused with400, so references cannot be read off the reports and replayed.
One case remains. A payment linked by user id or email (or by a return page of a signed-in user) goes to that user's latest visit before the payment, whichever browser made it. Someone who knows a user's id and opens your site with it in the minutes between that user's checkout and their payment becomes the linked visit, and their own affiliate link would then count. Use user ids that cannot be guessed, and prefer a checkout reference, which is exact.
Credit models
A buyer often visits several times before paying. Say someone found you through Hacker News on Monday, came back on Friday by typing your address, and paid. The payment is linked to that person and their Friday visit; the credit model decides which of their visits the reports credit.
| Model | Credited visit | In the example |
|---|---|---|
last_non_direct (default) | The latest visit within the lookback window that came from somewhere: its channel is neither Direct nor Internal. When there is none, the linked visit. | Hacker News |
first_visit | The person's first visit within the lookback window. | Hacker News |
last_visit | The linked visit (the checkout's, or the latest before paying), even when it was direct. | Direct |
- The lookback window is how far before the payment
last_non_directandfirst_visitlook: 90 days by default, 1 to 365. With a 3-day window in the example, Monday's visit is too old and both models credit Friday's. When no visit fits the window, the payment is credited to its linked visit. - Only visits up to the linked one count, and none more than 365 days before the payment.
- Choose the model and the window in Site settings → Revenue (
settings.revenue.attributionModelandattributionWindowDays). Every report applies them as soon as you save, without importing anything again: totals and charts under filters and segments, breakdowns, the payments list, journeys and the API. - The API's
model=last_non_direct,first_visitorlast_visitcompares another model on one request without changing the setting (/revenue/summary,/revenue/timeseries,/revenue/breakdown,/revenue/subscriptions/breakdown,/paymentsand a payment's journey).
Who the person is. A signed-in user you identify is recognized on every visit and device, so all their visits
count. Without cookies, a visitor who never signs in is recognized across days only while their network and browser
stay the same (how visitors are counted): a buyer who first came from a phone and paid later from a
laptop looks like two people, and the earlier visit cannot get the credit. In the daily identity mode an anonymous
visitor's earlier days are never linked.
Affiliates
When a payment is linked to a visit, PilotFox also credits it to the last affiliate link that the browser of that visit arrived through within the affiliate attribution window (30 days by default, 1 to 365) before the payment. Only that browser's own visits count, even for a user you identify on several devices: anyone can send another user's id, so an affiliate link opened under the same user id in another browser earns nothing. The credit model never changes which affiliate earns a payment.
Payment links and return pages
With Payment links and return pages on (Site settings → Tracking code, settings.tracking.paymentLinks, on by
default), the script links payments to visits in two cases without any code of yours.
Payment links
When a visitor clicks a link to a hosted payment page (a left click, a middle click, or a click that opens a new tab),
the script adds a fresh PilotFox reference to the link, in the parameter the provider copies onto the payment, and
sends a checkout event with it:
| Provider | Links | Parameter |
|---|---|---|
| Stripe | Payment Links on https://buy.stripe.com/ | client_reference_id |
| Dodo Payments | Static payment links on https://checkout.dodopayments.com/buy/ (or test.checkout.dodopayments.com in test mode) | metadata_pilotfox_ref (becomes metadata.pilotfox_ref) |
| Polar | Checkout Links on https://buy.polar.sh/, and the API's https://api.polar.sh/v1/checkout-links/… form (also sandbox-api.polar.sh) | reference_id (goes into the checkout's metadata) |
The link otherwise behaves exactly as before: the other parameters keep their exact encoding, target and modifier
keys are left alone, and the link's own href is restored right after the click, so the next click gets a fresh
reference. A link that already carries the parameter is left as it is and no event is sent. Nothing happens for a
visitor who opted out. Links on your own domain (a custom domain for payment links) and buttons that open the checkout
from code are not recognized: pass the reference yourself (option 1).
Return pages
When the page the provider sends the buyer back to carries the payment's own id in its address, the script sends a
checkout event with that id as its ref. PilotFox matches it up to an hour after the payment and links the payment to
that person's latest session started before it.
| Provider | What the return page needs |
|---|---|
| Stripe | {CHECKOUT_SESSION_ID} in the success URL, under any parameter name, for example https://example.com/thanks?session_id={CHECKOUT_SESSION_ID} (a Checkout Session's success_url, or a Payment Link's redirect after payment). Stripe replaces it with the session id (cs_live_… or cs_test_…). |
| Dodo Payments | Nothing: Dodo Payments adds payment_id (or subscription_id) to your return URL. It also adds the buyer's email and any license_key; on such a page the script removes those two from the URL it reports. |
| Polar | checkout_id={CHECKOUT_ID} in the success URL. Polar replaces it with the checkout id. |
A checkout reference sent before the checkout wins over a return page: it names the visit the buyer started the checkout from. A payment the sync reads in the few seconds before its return page is recorded is linked by user id or email instead, if it can be.