A first-party proxy lets your website load PureStats from your own domain instead of loading the script directly from `purestats.io`.

Use this when you want the browser Network panel, CSP and deployment checks to use your own origin, for example:

```html
<script
  defer
  src="https://example.com/pf.min.js"
  data-domain="example.com"
  data-api="https://example.com/api/event"
></script>
```

Keep `data-domain` set to the canonical domain in PureStats. The `data-api` attribute tells the tracker to send events to your proxy. PureStats automatically derives the config endpoint from it and requests `/api/tracker-config` on the same origin.

## Proxy paths

Proxy these paths from your website to PureStats:

| Your path | Upstream | Cache |
| --- | --- | --- |
| `/pf.min.js` | `https://purestats.io/pf.min.js` | Yes, about 1 hour |
| `/pf-test.min.js` | `https://purestats.io/pf-test.min.js` | Yes, about 1 hour |
| `/pf-vitals.min.js` | `https://purestats.io/pf-vitals.min.js` | Yes, about 1 hour |
| `/pf-experiments.min.js` | `https://purestats.io/pf-experiments.min.js` | Yes, about 1 hour |
| `/pf-events.min.js` | `https://purestats.io/pf-events.min.js` | Yes, about 1 hour |
| `/pf-search.min.js` | `https://purestats.io/pf-search.min.js` | Yes, about 1 hour |
| `/pf-replay.min.js` | `https://purestats.io/pf-replay.min.js` | Yes, about 1 hour |
| `/api/event` | `https://purestats.io/api/event` | No |
| `/api/tracker-config` | `https://purestats.io/api/tracker-config` | No, or very short |
| `/api/replay/chunks` | `https://purestats.io/api/replay/chunks` | No |

Do not cache `/api/event` or `/api/replay/chunks`. They receive analytics and optional replay payloads.

## Client IP trust

Register your proxy's egress IPs or CIDRs in the site's proxy settings before accepting forwarded client IPs. Derive the client address from the trusted connection, never from visitor-supplied forwarding headers. If Nginx or Apache sits behind another edge, configure its real-IP module with only that edge's trusted addresses first.

The Nginx and Apache examples strip incoming forwarding and PureStats signature headers and set a server-derived client IP. They do not generate signatures. HMAC signing is recommended; if `require_signature` is enabled, add a server-side signing layer or use the signed Cloudflare Worker below. Never put a signing key in browser code.

## Nginx

Add the cache zone in the `http` block:

```nginx
proxy_cache_path /var/cache/nginx/purestats levels=1:2 keys_zone=purestats_tracker:10m max_size=50m inactive=24h use_temp_path=off;
```

Add the locations to your site server block:

The static upstream below intentionally has no URI suffix or variables. Nginx preserves the original path and query string and resolves `purestats.io` with the system resolver at startup or reload; no runtime `resolver` directive is needed. Reload Nginx when upstream DNS changes. Adjust the trusted CA bundle path for your distribution, without disabling certificate verification.

```nginx
location ~ ^/pf(?:-events|-vitals|-experiments|-search|-replay|-test)?\.min\.js$ {
    proxy_pass https://purestats.io;
    proxy_ssl_server_name on;
    proxy_ssl_verify on;
    proxy_ssl_verify_depth 3;
    proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
    proxy_set_header Host purestats.io;
    proxy_set_header Cookie "";
    proxy_set_header Authorization "";
    proxy_cache purestats_tracker;
    proxy_cache_valid 200 1h;
    add_header Cache-Control "public, max-age=3600" always;
}

location ~ ^/api/(?:event|tracker-config|replay/chunks)$ {
    proxy_pass https://purestats.io;
    proxy_ssl_server_name on;
    proxy_ssl_verify on;
    proxy_ssl_verify_depth 3;
    proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
    proxy_set_header Host purestats.io;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header CF-Connecting-IP "";
    proxy_set_header Forwarded "";
    proxy_set_header X-PureStats-Proxy-Domain "";
    proxy_set_header X-PureStats-Proxy-Client-IP "";
    proxy_set_header X-PureStats-Proxy-Timestamp "";
    proxy_set_header X-PureStats-Proxy-Signature "";
    proxy_set_header Cookie "";
    proxy_set_header Authorization "";
    proxy_cache off;
}
```

## Apache

Enable `mod_proxy`, `mod_proxy_http`, `mod_ssl` and `mod_headers`, then add:

```apache
SSLProxyEngine On
ProxyPreserveHost Off

ProxyPass "/pf.min.js" "https://purestats.io/pf.min.js" retry=0
ProxyPassReverse "/pf.min.js" "https://purestats.io/pf.min.js"
ProxyPass "/pf-test.min.js" "https://purestats.io/pf-test.min.js" retry=0
ProxyPassReverse "/pf-test.min.js" "https://purestats.io/pf-test.min.js"
ProxyPass "/pf-vitals.min.js" "https://purestats.io/pf-vitals.min.js" retry=0
ProxyPassReverse "/pf-vitals.min.js" "https://purestats.io/pf-vitals.min.js"
ProxyPass "/pf-experiments.min.js" "https://purestats.io/pf-experiments.min.js" retry=0
ProxyPassReverse "/pf-experiments.min.js" "https://purestats.io/pf-experiments.min.js"
ProxyPass "/pf-events.min.js" "https://purestats.io/pf-events.min.js" retry=0
ProxyPassReverse "/pf-events.min.js" "https://purestats.io/pf-events.min.js"
ProxyPass "/pf-search.min.js" "https://purestats.io/pf-search.min.js" retry=0
ProxyPassReverse "/pf-search.min.js" "https://purestats.io/pf-search.min.js"
ProxyPass "/pf-replay.min.js" "https://purestats.io/pf-replay.min.js" retry=0
ProxyPassReverse "/pf-replay.min.js" "https://purestats.io/pf-replay.min.js"
ProxyPass "/api/event" "https://purestats.io/api/event" retry=0
ProxyPassReverse "/api/event" "https://purestats.io/api/event"
ProxyPass "/api/tracker-config" "https://purestats.io/api/tracker-config" retry=0
ProxyPassReverse "/api/tracker-config" "https://purestats.io/api/tracker-config"
ProxyPass "/api/replay/chunks" "https://purestats.io/api/replay/chunks" retry=0
ProxyPassReverse "/api/replay/chunks" "https://purestats.io/api/replay/chunks"

<LocationMatch "^/(?:pf(?:-test|-vitals|-experiments|-events|-search|-replay)?\.min\.js|api/(?:event|tracker-config|replay/chunks))$">
    RequestHeader unset Forwarded
    RequestHeader unset X-Forwarded-For
    RequestHeader unset X-Real-IP
    RequestHeader unset CF-Connecting-IP
    RequestHeader unset X-PureStats-Proxy-Domain
    RequestHeader unset X-PureStats-Proxy-Client-IP
    RequestHeader unset X-PureStats-Proxy-Timestamp
    RequestHeader unset X-PureStats-Proxy-Signature
    RequestHeader unset Cookie
    RequestHeader unset Authorization
    RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"
    RequestHeader set X-Real-IP "expr=%{REMOTE_ADDR}"
</LocationMatch>

<IfModule mod_headers.c>
    <Location "/pf.min.js">
        Header set Cache-Control "public, max-age=3600"
    </Location>
</IfModule>
```

## Cloudflare Worker

Create a Worker and route it to your domain for `/pf.min.js`, `/pf-test.min.js`, `/pf-vitals.min.js`, `/pf-experiments.min.js`, `/pf-events.min.js`, `/pf-search.min.js`, `/pf-replay.min.js`, `/api/event`, `/api/tracker-config` and `/api/replay/chunks`.

Set `domain` below to your canonical PureStats site domain. Rotate a signing key through the site's approved proxy settings, store the newly generated value only in the `PURESTATS_PROXY_SIGNING_KEY` Worker secret binding, and register the Worker's egress IPs or CIDRs. Enable `require_signature`. The key is not an OAuth credential and must never appear in the deployed script, a public environment binding or browser code.

Cloudflare supplies `CF-Connecting-IP` at its trusted edge. This example discards visitor-supplied forwarding and PureStats headers, signs the exact raw POST body with HMAC-SHA256, strips browser cookies and authorization, and caches successful JavaScript only in the edge Cache API. API responses are never cached.

```js
const allowed = ['/pf.min.js', '/pf-test.min.js', '/pf-vitals.min.js', '/pf-experiments.min.js', '/pf-events.min.js', '/pf-search.min.js', '/pf-replay.min.js', '/api/event', '/api/tracker-config', '/api/replay/chunks'];
const domain = 'example.com';
const hex = (buffer) => Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, '0')).join('');

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (!allowed.includes(url.pathname)) return fetch(request);
    const isScript = request.method === 'GET' && url.pathname.endsWith('.min.js');
    const cacheKey = new Request(url.toString(), { method: 'GET' });
    if (isScript) {
      const hit = await caches.default.match(cacheKey);
      if (hit) return hit;
    }
    const clientIp = request.headers.get('CF-Connecting-IP');
    const signingKey = env.PURESTATS_PROXY_SIGNING_KEY;
    if (!clientIp || typeof signingKey !== 'string' || signingKey.length < 32) {
      return new Response('Proxy signing is not configured', { status: 503 });
    }
    const headers = new Headers(request.headers);
    for (const name of Array.from(headers.keys())) {
      if (name.startsWith('x-purestats-proxy-') || name.startsWith('x-forwarded-') ||
          ['forwarded', 'x-real-ip', 'cookie', 'authorization', 'host'].includes(name)) headers.delete(name);
    }
    const body = ['GET', 'HEAD'].includes(request.method) ? null : await request.arrayBuffer();
    const timestamp = String(Math.floor(Date.now() / 1000));
    const bodyHash = hex(await crypto.subtle.digest('SHA-256', body ?? new Uint8Array()));
    const message = [domain.toLowerCase().trim(), clientIp.trim(), timestamp, bodyHash].join('\n');
    const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(signingKey),
      { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
    const signature = hex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)));
    headers.set('X-PureStats-Proxy-Domain', domain.toLowerCase().trim());
    headers.set('X-PureStats-Proxy-Client-IP', clientIp.trim());
    headers.set('X-PureStats-Proxy-Timestamp', timestamp);
    headers.set('X-PureStats-Proxy-Signature', signature);
    headers.set('X-Forwarded-Host', url.hostname);
    headers.set('X-Forwarded-Proto', url.protocol.slice(0, -1));
    headers.set('X-Forwarded-For', clientIp.trim());
    const upstream = new URL(url.pathname + url.search, 'https://purestats.io');
    const response = await fetch(new Request(upstream.toString(), { method: request.method, headers, body, redirect: 'manual' }));
    const result = new Response(response.body, response);
    result.headers.delete('Set-Cookie');
    if (isScript && result.status === 200) {
      result.headers.set('Cache-Control', 'public, max-age=3600');
      ctx.waitUntil(caches.default.put(cacheKey, result.clone()));
    } else result.headers.set('Cache-Control', 'no-store');
    return result;
  }
};
```

## Content Security Policy

If all PureStats requests go through your domain, a strict CSP can allow only your own origin:

```text
script-src 'self';
connect-src 'self';
img-src 'self' data:;
```

If you keep the normal direct script and only proxy events, also allow `https://purestats.io` in `script-src`.

## Verify

1. Open `https://example.com/pf.min.js` and confirm it returns JavaScript.
2. Install the first-party script snippet.
3. Open your site and check the browser Network panel.
4. Confirm `/pf.min.js`, enabled optional modules, `/api/tracker-config`, `/api/event` and `/api/replay/chunks` are requested on your own domain.
5. Open PureStats Site Health and verify the first hit.

## Troubleshooting

- If the script loads but no pageviews appear, check that `/api/event` forwards POST bodies.
- If settings do not apply, check that `/api/tracker-config?domain=example.com` reaches PureStats.
- If CSP blocks requests, allow your own origin in `script-src` and `connect-src`.
- If the proxy returns 404, make sure the route is configured before your app fallback route.
- If the proxy returns 405, make sure POST is allowed for `/api/event`.
- If the dashboard shows a domain mismatch, keep `data-domain` set to the canonical PureStats site domain and add every real hostname in Site Settings.
