Install the tracking script
Add the PilotFox script tag to your site, choose what it records, track single-page apps, allow it in a Content Security Policy and verify the installation.
The script tag
Put this tag in the <head> of every page, with your site's id in place of SITE_ID. The site's settings show the tag with the id filled in (Site settings → Tracking code).
<script defer src="https://pilotfox.app/api/s.js" data-site="SITE_ID"></script>
deferlets the browser download the script early and run it once the page has been parsed.- Keep it a classic
<script src>, nottype="module": the script finds its own tag throughdocument.currentScript. - If a page optimizer strips
data-*attributes, put the id in the URL instead:https://pilotfox.app/api/s.js?site=SITE_ID. - Load it at the start of the page, not later (for example after a click or a route change). A late script records the first pageview late and for the address of that moment, and misses errors thrown before it ran.
The script is small (under 10 KB compressed) and sets no cookies. It loads the site's settings from PilotFox, then sends each pageview and event as one request.
Install with an AI assistant
Site settings → Tracking code → Install with an AI assistant (and the last step of adding a site) has a Copy AI prompt button. Paste the prompt into Claude Code, Cursor or another coding assistant working in your site's codebase: it describes the tag with your site's id, single-page apps, identify for signed-in users and the checkout event that links payments to visits for each payment provider. It contains no keys or secrets.
What it records
These options are switches in Site settings → Tracking code. Browsers pick up a change within a minute.
| Option | Default | What it records |
|---|---|---|
| Initial pageview | On | A pageview when the script loads. |
| Single-page app navigation | On | A pageview on each History API route change (see below). |
| Hash routing | Off | #/path and #!/path changes as pageviews of that path. |
| Query strings | On | The page's query string with each pageview (what is stored of it is a privacy setting). |
| Outbound links | On | Clicks on links to other sites, with the link text (up to 200 characters). |
| Button clicks | Off | Clicks on buttons, submit inputs and role="button" elements, with their text. |
| Form submissions | Off | The form's id, name, action, method and number of fields, never the values. |
| Copy events | Off | That text was copied, from which kind of element and how much, never the text. |
| Input changes | Off | The name and type of a changed field, never the value. |
| JavaScript errors | Off | Uncaught errors and unhandled promise rejections. |
| Web vitals | Off | LCP, CLS, INP, FCP and TTFB, once per pageview. |
| Payment links and return pages | On | A checkout event with a fresh reference when a link to a Stripe, Dodo Payments or Polar payment page is clicked (the reference is added to the link), and with the payment's id on the page the provider sends the buyer back to. See Revenue. |
Session replay is a separate setting (Site settings → Session replay); see Privacy and data.
Single-page apps
With Single-page app navigation on, the script counts a pageview whenever the address changes through history.pushState, history.replaceState or the back and forward buttons. Changes within 300 ms are counted once, and a change to the same URL is not counted again. Set data-debounce to change the delay (in milliseconds).
To send pageviews yourself, turn Single-page app navigation off and call pilotfox.pageview() after each route change (or pilotfox.pageview(url) with the URL to record).
Script attributes
| Attribute | Meaning |
|---|---|
data-site | Your site's id. Required (or ?site= on the script URL). |
data-namespace | The name of the script's global object. Default pilotfox. |
data-debounce | Delay in milliseconds before a route change counts as a pageview. Default 300. |
data-skip | A JSON array of path patterns that are never recorded, for example ["/admin/**"]. |
data-mask | A JSON array of path patterns that hide part of a path, for example ["/orders/*"] records /orders/123 as /orders/*. |
In patterns, * matches one path segment and ** any number of segments, and a matching path is recorded as the pattern itself. A pattern that starts with re: is a regular expression; in data-mask, each match is replaced with *.
Content Security Policy
If your site sends a Content Security Policy, allow PilotFox in two directives:
script-src:https://pilotfox.app(the script, and the replay recorder when session replay is on);connect-src:https://pilotfox.app(settings, events, identifies and recordings).
Serve the script from your own domain
Some ad blockers block analytics scripts, and the requests they send, when they come from another domain. You can route them through your own site instead: your server forwards a path of your site, such as /_pf/, to PilotFox, and the page loads the script from that path. The script sends everything to the directory it was loaded from, so /_pf/s.js gets its settings from /_pf/c/… and sends its events to /_pf/e.
PilotFox counts visitors by IP address (see Privacy and data). Through a proxy every request comes from your server, so your server passes each visitor's address in the X-PilotFox-IP header, and a secret in X-PilotFox-Proxy-Secret proves that the header comes from your server. Without a valid secret the address header is ignored, so nobody else can choose the address a visit is counted from.
- In Site settings → Tracking code → Serve from your own domain, choose the path and click Create secret. Copy the secret: it is shown once. Store it on your server, for example as the environment variable
PILOTFOX_PROXY_SECRET. Never put it in code that runs in the browser. - Configure your server with one of the examples below (the settings page fills in your path and the PilotFox address).
- Replace the script tag with one that loads from your path:
<script defer src="/_pf/s.js" data-site="SITE_ID"></script>.
Each example forwards /_pf/* to https://pilotfox.app/api/*, sets X-PilotFox-IP from the connection your server received (never from a header your visitors can send), adds the secret, and drops your visitors' cookies: they belong to your site, and PilotFox reads none.
Next.js
// proxy.ts at the root of your app (Next.js 16).
// On Next.js 15 or older: name the file middleware.ts and the function middleware.
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const url = new URL(pathname.replace(/^\/_pf/, "/api") + search, "https://pilotfox.app");
const headers = new Headers(request.headers);
headers.delete("cookie"); // your visitors' cookies stay on your site
headers.set("x-pilotfox-proxy-secret", process.env.PILOTFOX_PROXY_SECRET ?? "");
// The visitor's address: x-forwarded-for on Vercel, which sets it. Self-hosted: the
// header your own front proxy sets, never one your visitors can send.
headers.set("x-pilotfox-ip", request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "");
return NextResponse.rewrite(url, { request: { headers } });
}
export const config = { matcher: "/_pf/:path*" };
Vercel sets x-forwarded-for to the visitor's address and overwrites any value the visitor sends. Behind your own load balancer, use the header it sets. Plain rewrites in next.config.js can forward the path but cannot add headers, so every visitor would have your server's address.
Caddy
# Inside your site's block in the Caddyfile.
# PILOTFOX_PROXY_SECRET is read from Caddy's environment.
handle_path /_pf/* {
rewrite * /api{path}
reverse_proxy https://pilotfox.app {
header_up Host {upstream_hostport}
header_up X-PilotFox-IP {client_ip}
header_up X-PilotFox-Proxy-Secret {$PILOTFOX_PROXY_SECRET}
header_up -Cookie
}
}
{client_ip} is the address of the connection, or the one your trusted proxies report if you set trusted_proxies.
nginx
# Inside your site's server block. Paste the secret where it says so
# (nginx reads no environment variables here). Behind a load balancer,
# set $remote_addr from it with the realip module first.
location /_pf/ {
proxy_pass https://pilotfox.app/api/;
proxy_ssl_server_name on; # send the name pilotfox.app for TLS (off by default)
proxy_set_header Host pilotfox.app;
proxy_set_header X-PilotFox-IP $remote_addr;
proxy_set_header X-PilotFox-Proxy-Secret "PASTE_THE_SECRET_HERE";
proxy_set_header Cookie ""; # your visitors' cookies stay on your site
}
Cloudflare Worker
// A Worker on the route example.com/_pf/* of your zone. Add the secret with
// `wrangler secret put PILOTFOX_PROXY_SECRET` (or the dashboard's Variables and Secrets).
export default {
async fetch(request, env) {
const { pathname, search } = new URL(request.url);
const url = new URL(pathname.replace(/^\/_pf/, "/api") + search, "https://pilotfox.app");
const headers = new Headers(request.headers);
headers.delete("cookie"); // your visitors' cookies stay on your site
headers.set("x-pilotfox-ip", request.headers.get("cf-connecting-ip") ?? "");
headers.set("x-pilotfox-proxy-secret", env.PILOTFOX_PROXY_SECRET);
return fetch(url, { method: request.method, headers, body: request.body });
},
};
Notes
- A request whose secret is wrong, was replaced or removed, or whose
X-PilotFox-IPis missing or not a single IP address is refused with400 Invalid proxy secret; a secret of another site with400 The proxy secret is for another site. Nothing from it is recorded. - Replace in the settings stops the old secret at once: update your server right after. Remove turns the proxy off: requests that still carry the secret are refused.
- If the secret leaks, replace it. It lets its holder choose the IP address of events for that one site, nothing else: it gives no access to your data.
- Visitors are located from their own address. Cloudflare's location headers are not used for requests that come through a proxy.
- Your Content Security Policy then needs only your own origin (
'self') for the script and its requests.
Verify the installation
Verify installation (in Site settings → Tracking code, and at the end of adding a site) loads your site's home page, over HTTPS and then HTTP if HTTPS does not answer, and looks for the PilotFox tag with your site's id. It reports one of: installed, not installed, a tag with another site's id, or that the page could not be loaded.
Then open the site's dashboard and visit your site in another tab: the live view shows the visit.
Keep traffic out
In Site settings:
- Allowed hostnames (Privacy & identity): events from any other hostname are dropped, for example from a staging copy that still carries the tag.
*.example.commatches every subdomain, at any depth (www.example.com,a.b.example.com), but notexample.comitself: list that separately. - Exclusions: drop traffic by IP address or range, country, path, user agent or network (ASN), for example your office or uptime monitors.
- Bots: bot traffic is dropped by default and shown in the Bots report instead.
- Your own browser: open any page of your site with
?pilotfox_ignore=trueadded to the address (for examplehttps://example.com/?pilotfox_ignore=true), or runpilotfox.optOut()in the browser console there. That browser is no longer counted on your site;?pilotfox_ignore=falseundoes?pilotfox_ignore=true(neveroptOut(), which may be a visitor's own choice), andpilotfox.optIn()undoes either. It is remembered in the browser'slocalStorage, so clearing site data undoes it too. - Tagged links: Site settings → Link builder adds campaign (
utm_*) or affiliate (?ref=) parameters to a link of your site, so visits from posts on X, LinkedIn, Medium or a newsletter, which often arrive without a referrer, are reported under their campaign.