PureStats documentation

First-party proxy

Serve PureStats tracking from your own domain with Nginx, Apache or Cloudflare Workers. Configure caching, trusted client IPs and verify event delivery.

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:

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

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.

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:

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.

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:

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.