Install the script
Add the script tag to every page, set its attributes, 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. Site settings → Tracking code
shows the tag with the id filled in.
<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 (and so itsdata-site) throughdocument.currentScript, which module scripts do not set. - Load it at the start of the page, not later (after a click, after a route change, or with a "lazy" loading strategy). A late script records the first pageview late and for the address of that moment, and misses errors thrown before it ran.
- If a page optimizer strips
data-*attributes, put the id in the URL instead:https://pilotfox.app/api/s.js?site=SITE_ID. - One tag per page. A second tag with the same namespace is ignored. A second tag with another
data-namespaceruns separately, under its own name.
In a single-page app, put the tag in the root layout or document template once: route changes are counted for you (see Single-page apps). For example, in a Next.js App Router layout:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<script defer src="https://pilotfox.app/api/s.js" data-site="SITE_ID"></script>
</head>
<body>{children}</body>
</html>
);
}Use a plain <script> tag as above rather than next/script with lazyOnload, which would load it late.
Install with an AI assistant
Site settings → Tracking code → Install with an AI assistant, and the last step of adding a site, have 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 how to link payments to
visits for each payment provider. It contains no keys or secrets.
Script attributes
| Attribute | Default | Meaning |
|---|---|---|
data-site | (required) | Your site's public id. Or ?site=SITE_ID on the script URL. |
data-namespace | pilotfox | The name of the global object (window.pilotfox): ASCII letters, digits, _ and $, not starting with a digit. Any other value falls back to pilotfox. It also prefixes the browser storage keys. |
data-debounce | 300 | Milliseconds to wait after a route change before counting it as a pageview. A negative or non-numeric value falls back to 300. |
data-skip | none | A JSON array of path patterns that are never recorded, for example ["/admin/**"]. Invalid JSON is ignored. |
data-mask | none | A JSON array of path patterns that hide part of a path, for example ["/orders/*"] records /orders/123 as /orders/*. Invalid JSON is ignored. |
Patterns match the page's path (with hash routing, the route's path):
- A plain pattern matches the whole path, case-sensitively.
*matches within one path segment and**across any number of segments:/admin/**matches/admin/users/7but not/adminitself (list both if you need both). A path that matches is recorded as the pattern itself (indata-mask) or not at all (indata-skip). - A pattern that starts with
re:is a regular expression, matched anywhere in the path. Indata-mask, each match is replaced with*. - In
data-mask, the first pattern that matches applies.
<script
defer
src="https://pilotfox.app/api/s.js"
data-site="SITE_ID"
data-skip='["/admin/**"]'
data-mask='["/orders/*", "re:[0-9a-f]{8}-[0-9a-f-]{27}"]'
></script>With this tag, /admin/users/7 sends no pageview, no event and no recording (identify() and setTraits() calls,
which carry no page address, are still sent), /orders/123 is recorded as /orders/*, and
/invoices/0b9d8f3e-1c2a-4b5d-9e8f-123456789abc/pdf as /invoices/*/pdf.
The URL fragment (#...) is never sent, except as a route with hash routing.
Content Security Policy
If your site sends a Content Security Policy, allow PilotFox in two directives:
| Directive | Allow | For |
|---|---|---|
script-src | https://pilotfox.app | The script, and the session replay recorder (/api/r.js) when session replay is on. |
connect-src | https://pilotfox.app | The settings request, events, identify and recordings. |
Content-Security-Policy: script-src 'self' https://pilotfox.app; connect-src 'self' https://pilotfox.app- With a nonce, give the tag the nonce: the replay recorder the script adds gets the same nonce, so
script-src 'nonce-...' 'strict-dynamic'works without listing PilotFox. - If
connect-src(ordefault-src, its fallback) does not allow PilotFox, the browser blocks the settings request and every event: nothing is recorded. - Served from your own domain, the script and its requests need only
'self'.
Verify the installation
Verify installation (Site settings → Tracking code, and the last step of adding a site) loads your site's home page,
over HTTPS and then over HTTP if HTTPS does not answer, and looks for a PilotFox tag with your site's id: a <script>
whose src ends in /s.js (so a tag served from your own domain counts) with your id in
data-site or ?site=. It reads the first 512 KB of the HTML your server sends (following up to 3 redirects, for 8
seconds at most per attempt): a tag that JavaScript adds to the page later is not seen. The result is one of:
| Result | reason in the API | Meaning |
|---|---|---|
| Installed | The home page has the tag with this site's id. | |
| Not installed | script_not_found | The page loaded, without the tag. |
| Wrong site id | wrong_site | The page has a PilotFox tag for another site. |
| Couldn't check | unreachable, timeout, blocked | The page could not be loaded (no answer or an error status), took longer than 8 seconds, or the domain points to a private or non-public address. |
The same check is available over the API (reference), with the
site:read permission and up to 10 checks a minute:
curl -X POST -H "Authorization: Bearer $PILOTFOX_API_KEY" \
-H "content-type: application/json" -d '{}' \
https://pilotfox.app/api/v1/sites/SITE_ID/verify-install{ "data": { "installed": true, "url": "https://example.com/", "status": 200 } }When the tag is not found, installed is false and reason says why, as in the table above. A check that could not
load the page is still a 200 answer.
Then visit your site in another tab and open the site's dashboard: the Online figure and the Realtime view show the visit as it arrives.
Troubleshooting
Open your site with the browser's developer tools on the Network tab and filter on pilotfox.app (or your proxy path):
| What you see | Likely cause |
|---|---|
No request to /api/s.js | The tag is missing from this page, or an ad blocker blocked it: serve it from your own domain. |
/api/s.js loads, nothing else | The tag is type="module" or injected without its data-site; or a Content Security Policy blocks connect-src (the console says so). |
/api/c/SITE_ID answers 404 | The site id is wrong, or the site was deleted. |
/api/c/... loads, no POST /api/e | This browser is opted out (?pilotfox_ignore=true or optOut() earlier: run pilotfox.optIn()), sends Global Privacy Control, the page matches data-skip, or Initial pageview is off. |
POST /api/e answers 204, nothing in the reports | The event was dropped on the server: Allowed hostnames, an exclusion (your own IP address, for example) or the bot filter. See Opt-out and excluding traffic. |
POST /api/e answers 429 | Too many requests from one address in a short time. |