PilotFox

Revenue tracking with Stripe, Dodo Payments or Polar

Connect Stripe, Dodo Payments or Polar with a read-only key, link payments to visits with a checkout event, choose which visit gets the credit, and see exactly which payments count.

PilotFox reads your payments from Stripe, Dodo Payments or Polar with a read-only key you give it, and credits each payment to the visit (and the affiliate) that led to it. Revenue then appears next to your traffic: by channel, referrer, campaign, landing page, country, device and every other dimension, with conversion rates. On any other billing system, your server records each payment through the API instead.

Connect a payment provider

A site can connect one account of each provider, and several providers at once: their payments add up in the same reports.

  1. In the provider's dashboard, create a key that can only read payments and refunds. Each provider's section below names the exact menu and permissions.
  2. In PilotFox, open Site settings → Revenue, choose the provider, paste the key and choose how many days of past payments to import (1 to 365, 90 by default). You need the editor role.
  3. PilotFox reads once with each permission before it saves the key. If one is missing, it says which.

The key is stored encrypted and is never shown again; the settings show only its last characters and whether it is a live or test key. A test key imports test-mode payments only.

PilotFox imports the chosen history first, then checks the provider for new payments and refunds every few minutes (every minute while it is still catching up). Sync now asks for a check right away. No webhook is needed: PilotFox only reads.

Match again imports the whole chosen history again with the same key and links every payment with today's data: use it after you start calling identify, sending the checkout event or sending your users' emails from your server, so payments imported before are linked too. Reports keep their numbers meanwhile; links improve as the import goes.

What counts as a payment

  • Money the customer actually paid, with an amount above zero. Unpaid, failed, cancelled or pending payments and free (zero) checkouts are not counted. Each provider's section lists exactly which of its payments count.
  • Full and partial refunds are subtracted from the payment they refund. A refund counts against the payment's date, not the refund's.
  • A payment's time is when it was paid. Amounts include tax, as the customer paid them.
  • Amounts are kept in the payment's own currency. Reports show one currency at a time, with no conversion: the site's default currency (Site settings → Revenue), or another one picked on the Revenue page.
  • Revenue is the amount paid minus the amount refunded. The reports also show gross revenue, refunds, the number of payments, average order value (gross revenue ÷ payments), paying visitors, conversion rate (paying visitors ÷ visitors) and revenue per visitor (revenue ÷ visitors, over the same period and filters).
  • On the dashboard, a site with revenue shows it as bars under the visitors line, per day (or hour, week, month) of payment; the chart's legend switches the bars on and off.
  • Payments that could not be linked to a visit still count in the totals. When you filter the reports or apply a segment, only payments whose credited visit matches are counted (see Credit models).

How payments are linked to visits

A payment provider does not know which visit a payment came from, so PilotFox matches each payment in this order and stops at the first match:

  1. Checkout reference. The reference the payment carries, its metadata.pilotfox_ref (also Stripe's client_reference_id and Polar's reference_id, but only as PilotFox references), equals the ref property of a checkout event sent from your page in the 7 days before the payment. The payment is linked to the session of the first such event: the click that created the reference. A later checkout event repeating the reference, from any visit, is ignored, so create a fresh, unguessable reference for each checkout. 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 (see Payment links and return pages): the payment is linked to that visitor's latest session started before the payment.
  2. User id. The payment's metadata.pilotfox_user_id equals a user id you passed to identify. The payment is linked to that user's latest session before the payment.
  3. Email. The customer's email equals, ignoring case, the email trait your server sent for an identified user (option 3). The payment is linked to that user's latest session before the payment. An email trait set in the browser is never used here.
  4. Same customer. The payment provider's own customer (Stripe's customer id) paid earlier on this site and that earlier payment is linked. The payment is linked like the customer's latest earlier linked payment: a renewal or a repeat order belongs to the person whose first order you linked, even when it carries no reference. It works in both directions during an import: a later payment imported before the first one is linked once the first one arrives.

Otherwise the payment is unlinked. Where each provider takes the metadata (a checkout session, a payment link) is in its section below. A linked payment belongs to a person, and the credit model then picks which of their visits the reports credit.

Subscriptions. 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 comes before the steps above, same customer included: a renewal stays with the visit that started its subscription even when it carries a reference of its own. When the first payment is imported after its renewals, the renewals follow it once it arrives.

Option 1: a checkout reference (any visitor)

When the visitor starts checkout, create a reference, send it with a checkout event and pass the same reference to the provider when your server creates the checkout.

// In the page, when the visitor clicks your checkout button:
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 section has the code).

Option 2: the user id (signed-in buyers)

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 },

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": "USER_ID", "ip": "BROWSER_IP", "user_agent": "BROWSER_USER_AGENT", "traits": {"email": "buyer@example.com"}}'

A payment whose customer email matches is then linked to that user's latest visit. Only an email your server sends this way 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 PilotFox (on their profile, or when identifying a visitor by hand from a session) never makes an email link payments, and removing or changing the email there stops payments linking by the old address until your server sends one again. Emails stored before PilotFox made this distinction (October 2026) do not link payments until your server sends them again; then use Match again. 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 a payment:

  • An affiliate is credited only from the affiliate links of the visitor (the browser) the payment is linked to (below).
  • A checkout reference counts from the first event that carried it, and a Stripe client_reference_id or Polar reference_id counts as one only in the script's format (below).
  • Only an email sent by your server links a payment.

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.

References and your own ids

Stripe's client_reference_id and Polar's reference_id hold either a PilotFox reference or your own id (a user, cart or order id), never both:

  • A PilotFox reference: what the script adds to a payment link, or one you make the same way, "pf" + crypto.getRandomValues(new Uint32Array(4)).join("") (pf and digits). PilotFox matches it against checkout events.
  • Anything else is read as a user id only (option 2), never matched against checkout events: your own ids are often easy to guess, and anyone could send a checkout event carrying one from their own visit and take the payment.

A reference your server makes in any other format, such as crypto.randomUUID(), goes in metadata.pilotfox_ref. Until October 2026 these docs suggested client_reference_id for any reference: move such references to metadata.pilotfox_ref.

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 with it: whose channel, referrer, campaign, landing page, country and device the revenue counts for.

  • Last non-direct (the default): the latest visit that came from somewhere (a search, a link, an ad, an email, an AI assistant) within the lookback window. A direct visit (typed in or bookmarked, no referrer) or one from your own site gets the credit only when every visit in the window was one. In the example: Hacker News.
  • First visit: the person's first visit within the lookback window, what first brought them to you. In the example: Hacker News as well.
  • Last visit: the visit the payment is linked to (the checkout's, or the latest before paying), even when it was direct. In the example: Direct.

The lookback window (90 days by default, 1 to 365) is how far before the payment last non-direct and first visit look. In the example, with a 3-day window Monday's visit is too old: both models credit Friday's. When no visit fits the window, the payment is credited to its linked visit.

Choose the model and the window in Site settings → Revenue. Every report applies them as soon as you save: totals and the chart under filters and segments (a payment counts when its credited visit matches), the breakdowns, the payments list and the API. Nothing is imported again. On the Revenue page, Credit to compares another model without changing the setting. In the API, model=last_non_direct, first_visit or last_visit does the same on /revenue/summary, /revenue/timeseries, /revenue/breakdown, /payments and 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 on mobile data and paid later from a laptop at home looks like two people, and the earlier visit cannot get the credit. In daily identity mode an anonymous visitor's earlier days are never linked. Visits more than 365 days before the payment are not considered.

Payments imported before credit models existed get their credits from a background job right after the update; until then every model credits them to their linked visit.

With Payment links and return pages on (Site settings → Tracking code; on by default), the script links payments to visits in two cases without any code of yours.

Payment links. When a visitor clicks (or middle-clicks, or opens in a new tab) a link to a hosted payment page, the script adds a fresh reference to the link, in the parameter the provider copies onto the payment, and sends a checkout event with it:

ProviderLinksParameter
StripePayment Links (buy.stripe.com)client_reference_id
Dodo PaymentsStatic payment links (/buy/ on checkout.dodopayments.com, or test.checkout… in test mode)metadata_pilotfox_ref (becomes metadata.pilotfox_ref)
PolarCheckout Links (buy.polar.sh, and the API's /v1/checkout-links/…/redirect form)reference_id (goes into the checkout's metadata)

The link otherwise works exactly as before: the other parameters, target and the visitor's modifier keys are left alone, and the page's own href is restored right after the click. A link that already carries that parameter is left as it is and no event is sent. Links on your own domain (a custom domain for payment links) and buttons that open the checkout from code are not recognised: 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. PilotFox matches it up to an hour after the payment.

ProviderWhat the return page needs
StripeAdd {CHECKOUT_SESSION_ID} to the success URL, 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.
Dodo PaymentsNothing: Dodo Payments adds payment_id (or subscription_id) and status to your return URL. It also adds the buyer's email and any license_key; the script never reports those two.
PolarAdd checkout_id={CHECKOUT_ID} to the success URL. Polar replaces it with the checkout id.

A reference sent before the checkout wins over a return page: it names the visit the buyer started the checkout from. A payment the sync happens to read in the few seconds before its return page is recorded is linked by user id or email instead, if it can be.

References and payment ids link payments, so they are shown only to people who can see sessions: on a share link that does not include sessions and on a public dashboard, the Events report leaves out the ref property of checkout events, and reports cannot be filtered by a ref property.

Affiliates and revenue

When a payment is linked to a visit, PilotFox also looks for the last affiliate link the visitor of that visit (the same browser) arrived through within the attribution window (30 days by default) before the payment, and credits the payment to that affiliate. 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. This rule is the affiliates' own: the credit model never changes which affiliate earns a payment. See Affiliates.

Payments, journeys and customer emails

  • The payments list shows each payment's amount, status (paid, partially refunded, refunded), how it was linked and the credited visit's source, and its provider when more than one is connected. It can be searched by payment id, source or referrer.
  • A payment's journey shows the buyer's sessions before the payment (up to 50, from the last 365 days), oldest first, with each one's pages, a Credited badge on the visit each credit model credits, and the time from the first visit to the payment. It needs the permission to see sessions.
  • Store customer emails (off by default, Site settings → Revenue) keeps the email the provider has for each buyer with the payment, so you can search by email. Only people who can see sessions see emails. Turn it on only if your privacy policy covers it.

Subscription metrics

When your payment provider's subscriptions are synced (on Stripe or Dodo Payments, with Read access to Subscriptions; on Polar, with the subscriptions:read scope), the Revenue page shows a Subscriptions section for the selected period and currency. Filters and segments don't apply to it. The definitions are Stripe's Billing analytics ones, for every provider (each provider's section below notes where its plan prices differ):

  • MRR (monthly recurring revenue): the plan price of every active and past_due subscription, normalized to a month (a yearly price ÷ 12, a weekly one × 52 ÷ 12, a daily one × 365 ÷ 12), at the end of the period (or now). Trials, paused, unpaid and canceled subscriptions count 0. The plan price is the list price times the quantity, before tax and discounts; usage-based (metered) and tiered prices are left out.
  • MRR movements in the period: new (a subscription paying for the first time, a trial converting included), expansion (an upgrade), reactivation (paying again after stopping), contraction (a downgrade) and churned (canceled, unpaid or paused). MRR at the end = MRR at the start + new + expansion + reactivation − contraction − churned.
  • Subscribers: customers with a paying subscription; a customer with several subscriptions counts once.
  • Churn rate: subscribers who stopped paying in the last 30 days ÷ (subscribers 30 days before + new subscribers of those 30 days), at the end of the period.
  • ARPU: MRR ÷ subscribers. LTV: ARPU ÷ churn rate, shown once subscribers churn.
  • Trials: subscriptions in a free trial at the end of the period, and trials that started in it.
  • MRR by source: today's MRR by the channel, source or campaign of the visit each subscription is credited to: the visit its first linked payment is credited to under the site's credit model, or the one picked in Credit to.

PilotFox keeps each subscription's history from the moment it is first synced: the start, the end of a trial and a cancellation come from the provider's own dates, so the history before you connected is rebuilt from them; plan changes before that are not known (the current plan is assumed since the start), and a subscription that turned unpaid or paused before it was first synced is counted as stopping when it was synced.

Disconnecting or changing the key

  • Disconnect deletes the provider's key and stops its syncing. Its imported payments (and subscriptions) stay in your reports unless you also choose to delete them; other providers' are never touched.
  • Replace key checks the new key the same way. If it cannot read the site's latest payment imported from that provider (a different account, or test instead of live), that provider's imported payments are deleted first, so accounts and modes never mix.
  • Erasing a user's data keeps their payments in the totals but removes the link to the user, their sessions and their email.

Any other billing (API)

On Paddle, Lemon Squeezy, Gumroad, crypto or a billing system of your own, record each payment from your server through the API. It is linked to a visit in the same order as a synced payment, credited by the site's credit model, and counts in every revenue report, the affiliates and the buyer's journey.

  1. An organization admin creates an API key with the revenue:write permission, best limited to the site. Site settings → Revenue → Custom / API links to the form with that permission and the site already chosen. Keep the key on your server: only API keys can record payments, never a signed-in browser or a share link.
  2. When a payment succeeds (in your billing system's webhook handler, or after your own checkout), send it:
curl -X POST "https://pilotfox.app/api/v1/sites/SITE_ID/payments" \
  -H "Authorization: Bearer $PILOTFOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"payment_id": "order_1042", "amount": 4900, "currency": "usd", "ref": "3f2c9b1e-8f4d-4a8e-9c1b-2d7e5a6f0b13", "email": "buyer@example.com"}'
FieldWhat it is
payment_id (required)Your id of the payment (order, transaction or invoice id): 1 to 255 printable ASCII characters, no spaces. A site has one payment per id: sending the same id again updates it, so a retried webhook changes nothing.
amount (required)What the customer paid, tax included, as a whole number in the currency's smallest unit, the way Stripe counts it: 4900 is 49.00 USD, 1200 is 1,200 JPY (a zero-decimal currency), 5120 is 5.120 KWD. No decimal point.
currency (required)ISO 4217 code, such as usd or EUR.
tsWhen it was paid, ISO 8601 with a time zone (2026-10-26T09:30:00Z), from 2000-01-01T00:00:00Z to at most 5 minutes ahead of the server's clock. By default: now, or the time already recorded for this payment_id.
refunded_amountThe total refunded so far, from 0 to amount. Or status: paid (nothing refunded) or refunded (all of it). By default: 0, or what was recorded before.
refThe ref your page sent with its checkout event (option 1).
user_idThe id you passed to identify for the buyer (option 2).
customer_idYour billing system's customer id. A later payment of the same customer that nothing else links (a renewal, a second order) is linked like this one (same customer), whichever of the two you record first. It is also matched like user_id, for sites that identify users by it.
emailThe customer's email (option 3); kept with the payment only when Store customer emails is on.

The answer is 201 for a new payment and 200 for an update, with the payment and how it was linked: attribution is checkout_ref, user_id, email, customer or none. A field PilotFox does not know, an amount with decimals, an unknown currency or a ts outside that range is refused (400).

  • Updates. A request that sends none of ref, user_id, customer_id and email keeps the payment's link to its visit, so a refund or a status change never loses it; one that sends any of them links the payment again. An email or customer_id not sent keeps the recorded one. A different ts or currency replaces the recorded payment.
  • Refunds. POST /api/v1/sites/SITE_ID/payments/PAYMENT_ID/refund refunds the whole amount. With a body {"refunded_amount": 1000} it sets the total refunded so far, so sending it twice changes nothing.
  • Test payments. DELETE /api/v1/sites/SITE_ID/payments/PAYMENT_ID removes a payment from every report. Only payments recorded through the API can be refunded or deleted this way.
  • In those paths, URL-encode the payment id (encodeURIComponent) if it has characters such as / or #.
  • Limits. Each of these three calls allows 300 requests a minute per key. Of two requests for the same payment at the same moment, one may answer 409: retry it. Revenue totals and charts are cached for 30 seconds by default (on a self-hosted server, QUERY_CACHE_TTL_SECONDS, at most 5 minutes), so a new payment can take that long to show in them. The payments list is never cached and shows it at once.

Every field and answer is also in the API reference.

Stripe

Create a Stripe key

In the Stripe Dashboard, open API keys and click Create restricted key. Give it Read access to Checkout Sessions, Payment Intents, Charges and Refunds, and Events, and leave everything else at None. A secret key works too, but a restricted key can only read. Keys start with rk_live_ or rk_test_ (restricted) and sk_live_ or sk_test_ (secret); a test key imports test-mode payments.

If you sell subscriptions, also give the key Read access to Invoices and Subscriptions (both under Billing): with Invoices PilotFox counts every renewal, with Subscriptions it reports the subscription metrics. Without them, everything else still imports and the connection says what is missing; after adding a permission to the same key in Stripe, click Sync now.

PilotFox checks Stripe every 10 minutes once it has caught up, which keeps well inside the read allowance Stripe gives each account.

Which Stripe payments count

CountedNot counted
Paid Checkout Sessions, including those created by Payment LinksPayments made without Checkout or a subscription (for example PaymentIntents you create yourself, or one-off invoices)
Payments by delayed methods, once Stripe marks them paidCheckout Sessions that are unpaid, expired or have a total of 0 (free trials, setup mode)
Full and partial refunds of those paymentsUnpaid, void or uncollectible invoices, and $0 invoices (a trial's)
The first payment of a subscription started in CheckoutRenewals and their refunds, when the key cannot read Invoices
With Read access to Invoices: every paid subscription invoice (renewals, upgrades, prorations), and the refunds of each, the first payment's included

A Stripe payment's time is when it was charged; a renewal's, when its invoice was paid. A subscription started in Checkout counts its first payment once, as the Checkout Session (its id starts with cs_), and each renewal as its invoice (in_). A subscription created without Checkout (through the API or the Dashboard) counts its first invoice too.

Renewals are credited to the visit that started the subscription: every payment of a subscription is linked like its earliest linked payment (normally the first), affiliate included, so a renewal months later still counts for the channel, campaign or affiliate that brought the subscriber. A subscription created without Checkout is linked through the subscription's own metadata.pilotfox_user_id (or pilotfox_ref), which Stripe copies onto each of its invoices, or else through the customer's email.

Pass the reference to Stripe

Set the reference as metadata.pilotfox_ref. Metadata can be set on the Checkout Session (metadata) or on its payment (payment_intent_data.metadata); PilotFox reads both. A client_reference_id is a reference only in the script's format; any other value, such as your user id, links the payment to the identified user with that id (references and your own ids).

// On your server (Node.js, the stripe package):
app.post("/create-checkout", async (req, res) => {
  const ref = String(req.body.ref ?? "");
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: "price_123", quantity: 1 }],
    success_url: "https://example.com/thanks",
    metadata: { pilotfox_ref: ref.slice(0, 500) }, // metadata values are up to 500 characters
    // A signed-in buyer: client_reference_id: user.id (or metadata: { pilotfox_ref: ref, pilotfox_user_id: user.id }),
  });
  res.json({ url: session.url });
});

With Payment links and return pages on, the script adds a reference to Payment Links as they are clicked (above). To do it yourself, for example for links on your own domain: a Payment Link accepts the reference as its client_reference_id URL parameter (Stripe accepts letters, digits, dashes and underscores, up to 200 characters, and silently drops other values). Make it the way the script does, pf and digits: in any other format PilotFox reads it as a user id (references and your own ids):

document.querySelectorAll('a[href^="https://buy.stripe.com/"]').forEach((a) => {
  a.addEventListener("click", () => {
    const ref = "pf" + crypto.getRandomValues(new Uint32Array(4)).join(""); // a PilotFox reference
    window.pilotfox?.event("checkout", { ref });
    const url = new URL(a.href);
    url.searchParams.set("client_reference_id", ref);
    a.href = url.toString();
  });
});

Dodo Payments

Create a Dodo Payments key

In the Dodo Payments dashboard, switch to the mode you want to import (live or test), go to Developer → API Keys and select Create New Key. Give it Read access to Payments and Refunds, and leave every other resource at None. Dodo keys belong to one mode and have no visible prefix: PilotFox finds out whether a key is live or test when it checks it, and a test key imports test-mode payments only.

If you sell subscriptions, also give the key Read access to Subscriptions: PilotFox then reports the subscription metrics. Without it, payments (renewals included) still import and the connection says what is missing; after changing the key's access in Dodo (Edit Permissions on the key), click Sync now.

If your business does not have live payments yet (account verification is not finished), a live key is refused with a message that says so: connect a test key until Dodo enables live payments.

By default Dodo allows each business 240 API requests a minute, shared with your own integration. PilotFox syncs with at most one request a second and checks every 5 minutes once it has caught up.

Which Dodo payments count

CountedNot counted
Succeeded payments, from checkout sessions, payment links or the APIFailed, cancelled, processing or abandoned payments (for example Requires payment method)
Subscription renewals, each as its own payment, credited to the visit of the subscription's first paymentPayments of 0, such as a payment-method update or a free trial's start
Bank debits (ACH, SEPA) once Dodo marks them succeededDisputes, which are not refunds
Full and partial refunds once they succeedRefunds still pending or in review (counted when they succeed)
  • A Dodo payment's time is when its checkout created it: Dodo does not report when a payment completed, so a bank debit that clears days later keeps its checkout day.
  • The amount is the total the customer paid, tax included. A refund is subtracted up to the payment's amount: with Adaptive Currency a refund in the customer's currency can come to more than they paid.
  • A payment that succeeds days after checkout (ACH takes up to 4 business days, SEPA up to 6) is picked up by a re-check of the last 14 days every 6 hours.

Pass the reference to Dodo Payments

Put the reference in the Checkout Session's metadata as pilotfox_ref. Dodo stores a Checkout Session's metadata on the payment it creates. You can also send the session's own session_id (returned when you create the session) as the checkout event's ref: a payment is linked by its checkout session id too.

// On your server (Node.js, the dodopayments package):
const dodo = new DodoPayments({
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
  environment: "live_mode", // "test_mode" with a test key
});

app.post("/create-checkout", async (req, res) => {
  const ref = String(req.body.ref ?? "");
  const session = await dodo.checkoutSessions.create({
    product_cart: [{ product_id: "pdt_123", quantity: 1 }],
    return_url: "https://example.com/thanks",
    metadata: { pilotfox_ref: ref.slice(0, 500) }, // metadata values are up to 500 characters
    // A signed-in buyer: metadata: { pilotfox_ref: ref, pilotfox_user_id: user.id },
  });
  res.json({ url: session.checkout_url });
});

With Payment links and return pages on, the script adds the reference to payment links as they are clicked (above). To do it yourself: a payment link (https://checkout.dodopayments.com/buy/<product id>) takes custom metadata as metadata_<key> URL parameters, so metadata_pilotfox_ref carries the reference:

document.querySelectorAll('a[href^="https://checkout.dodopayments.com/buy/"]').forEach((a) => {
  a.addEventListener("click", () => {
    const ref = crypto.randomUUID();
    window.pilotfox?.event("checkout", { ref });
    const url = new URL(a.href);
    url.searchParams.set("metadata_pilotfox_ref", ref);
    a.href = url.toString();
  });
});

A renewal is credited to the same visit as its subscription's first linked payment, so linking the checkout that starts a subscription links its renewals too. Later payments of the same Dodo customer that nothing else links reuse the link of their earlier payment.

A payment is also linked to an identified user by pilotfox_user_id in the payment's metadata or in its Dodo customer's metadata. Subscription renewals are charged without a checkout and carry the subscription's metadata, which may not include your checkout's. For a subscription whose first payment could not be linked, create the Dodo customer with the user id and attach that customer to each checkout:

const customer = await dodo.customers.create({
  email: user.email,
  name: user.name,
  metadata: { pilotfox_user_id: user.id },
});
// then, in dodo.checkoutSessions.create: customer: { customer_id: customer.customer_id },

Renewals without either still match by the customer's email when your server sent it for the user (option 3).

Dodo subscription metrics

With Read access to Subscriptions, PilotFox reads every subscription once when you connect (whatever its age), again every 6 hours, and a subscription right away when a new payment of it arrives. Dodo's statuses map to the metrics' as follows:

Dodo PaymentsSubscription metrics
activeactive (trialing during its trial)
past_duepast due
on_hold (a renewal failed; renewals stopped)unpaid
pausedpaused
cancelled, expiredcanceled
failed (never started)incomplete
pendingnot counted until it becomes one of the above
  • The plan price is Dodo's recurring amount before tax for each charge, with its quantity, per billing interval (its payment frequency). Dodo gives no price before discounts, so a discounted subscription counts at its discounted price; a tax-inclusive price keeps its tax.
  • Dodo has no trial status: a subscription is in its trial for its trial period days from its creation. A trial extended by moving the next billing date counts as active once the original trial period is over.
  • On-demand subscriptions (charged a variable amount whenever you ask) have no plan price and are left out.
  • A cancellation or a pause is dated by Dodo. A hold or a missed renewal on an old subscription with no new payment is seen within 6 hours, and dated when it is seen.

Known limits

  • Key access. Read access to Payments and Refunds is all PilotFox asks for. Dodo's documentation does not say whether reading a payment, which includes its customer's email and metadata, could also need Customers access; PilotFox checks both reads when you connect, so a key that is not enough is refused right away.
  • Payment-link parameters. Dodo documents metadata_<key> URL parameters with one example (metadata_orderId=123); metadata_pilotfox_ref follows that pattern. If your payments never show a checkout reference, use a Checkout Session instead, whose metadata is documented to reach the payment.
  • Renewals. Whether a Checkout Session's metadata is copied onto the subscription it starts is not documented, hence the customer metadata above.
  • Refund amounts. Dodo does not say whether a refund's amount includes tax. PilotFox subtracts the refund as Dodo reports it, never more than the payment, and a full refund is always the whole payment.
  • Payment time. Payments are dated by checkout, not by completion (see above).
  • Several brands. A Dodo business can have several brands; PilotFox imports the payments and subscriptions of all of them.
  • A payment for several subscriptions. A checkout that starts several subscriptions at once makes one payment that names none of them; its subscriptions' renewals are then linked on their own (customer, user id or email).

Polar

Create a Polar access token

In Polar, open Settings → Access Tokens for your organization and click Create token. Give it a name, an expiration date and the scopes orders:read and refunds:read. If you sell subscriptions, add subscriptions:read for the subscription metrics; without it, payments and renewals still sync and the connection says what is missing. To add it later, either add the scope to the token in Polar (its Update button) and click Sync now, or create a new token with it and use Replace key. Add organizations:read if you want the settings to show your organization's name. The token starts with polar_oat_.

Polar's sandbox (sandbox.polar.sh) is a separate server with its own tokens. A sandbox token works the same way and imports sandbox orders only; PilotFox finds out which server a token belongs to when it checks it, and the settings show a sandbox token as a test key.

A Polar token stops working on the expiration date you chose, or when it is revoked (Polar revokes tokens that leak publicly). The sync then stops and the settings say the key was rejected: create a new token and use Replace key.

PilotFox checks Polar every 5 minutes once it has caught up, far below the rate limit Polar gives your organization, which your own integration shares.

Which Polar payments count

CountedNot counted
Paid orders of one-time purchasesPending orders, until they are paid
The first payment of a subscription and every renewalVoid orders, and draft orders not charged yet
Plan changes and usage charges that Polar bills as ordersOrders of 0 (free products, 100% discounts, totals covered by a credit)
Full and partial refunds, tax included

The amount is the order's total with tax, less any credit from the customer's balance applied to it: what the customer was charged. A Polar payment's time is when the order was created, so a renewal paid after a failed attempt keeps its renewal date. Orders still pending, for example a renewal Polar is retrying, are checked again until they are paid or given up.

When your server creates the checkout, put the reference in metadata.pilotfox_ref: Polar copies the checkout's metadata to the order. For a signed-in buyer, set the checkout's external_customer_id to the id you pass to identify, or put that id in metadata.pilotfox_user_id. Renewals keep their subscription's metadata and customer, so they are linked to the same user.

// On your server (Node.js, @polar-sh/sdk), with a token that can create checkouts:
import { createPolar } from "@polar-sh/sdk/2026-10";
const polar = createPolar({ accessToken: process.env.POLAR_ACCESS_TOKEN });

app.post("/create-checkout", async (req, res) => {
  const ref = String(req.body.ref ?? "");
  const checkout = await polar.checkouts.create({
    products: ["<your product id>"],
    success_url: "https://example.com/thanks",
    metadata: /^[\w-]{1,200}$/.test(ref) ? { pilotfox_ref: ref } : {},
    // A signed-in buyer: external_customer_id: user.id,
  });
  res.json({ url: checkout.url });
});

With Payment links and return pages on, the script adds the reference to Checkout Links as they are clicked (above). To do it yourself: a Checkout Link accepts the reference as its reference_id URL parameter. Polar adds it to the checkout's metadata, and PilotFox reads it like pilotfox_ref when it has the script's format, pf and digits, and as a user id otherwise (references and your own ids):

// Mark your checkout link anchors, e.g. <a href="…" data-checkout-link>:
document.querySelectorAll("a[data-checkout-link]").forEach((a) => {
  a.addEventListener("click", () => {
    const ref = "pf" + crypto.getRandomValues(new Uint32Array(4)).join(""); // a PilotFox reference
    window.pilotfox?.event("checkout", { ref });
    const url = new URL(a.href);
    url.searchParams.set("reference_id", ref);
    a.href = url.toString();
  });
});

Polar subscriptions

Each renewal is its own order and counts as a payment. A subscription's renewals are credited to the visit of its first order, so a renewal months later never needs a reference of its own; a later order of the same Polar customer that nothing else links is credited like their earlier one.

With subscriptions:read, PilotFox reads every subscription once, then checks them again every hour (or when you click Sync now): new ones, plan changes, trials, cancellations and ends show in the subscription metrics within the hour. The plan price is the subscription's price before its discount, seats and units included, usage-based parts left out. If the token cannot read subscriptions, PilotFox asks again every few hours and when you click Sync now.

Polar limits

  • A subscription's price is as listed in Polar: a tax-inclusive price (Polar's default outside the United States, Canada and India) counts in MRR with its tax, since Polar gives no net price for a subscription.
  • If Polar takes back a refund after it succeeded (the refund later fails or is cancelled), PilotFox only notices when it reads that order again, for example after another refund of it.
  • PilotFox uses version 2026-10 of Polar's API. Polar supports each API version for about nine months, and a PilotFox update moves to the next one before it is retired. If Polar ever stops accepting it, the settings say so.

Last updated · Markdown version