# 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).

```html
<script defer src="https://pilotfox.app/api/s.js" data-site="SITE_ID"></script>
```

- `defer` lets the browser download the script early and run it once the page has been parsed.
- Keep it a classic `<script src>`, not `type="module"`: the script finds its own tag through `document.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](https://pilotfox.app/docs/revenue#payment-links-and-return-pages). |

Session replay is a separate setting (Site settings → Session replay); see [Privacy and data](https://pilotfox.app/docs/privacy#session-replay).

## 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](https://pilotfox.app/docs/privacy#counting-visitors-without-cookies)). 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.

1. 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.
2. Configure your server with one of the examples below (the settings page fills in your path and the PilotFox address).
3. 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

```ts
// 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

```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

```js
// 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-IP` is missing or not a single IP address is refused with `400 Invalid proxy secret`; a secret of another site with `400 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.com` matches every subdomain, at any depth (`www.example.com`, `a.b.example.com`), but not `example.com` itself: 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=true` added to the address (for example `https://example.com/?pilotfox_ignore=true`), or run `pilotfox.optOut()` in the browser console there. That browser is no longer counted on your site; `?pilotfox_ignore=false` undoes `?pilotfox_ignore=true` (never `optOut()`, which may be a visitor's own choice), and `pilotfox.optIn()` undoes either. It is remembered in the browser's `localStorage`, 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.
