Skip to content

First-party proxy

Serve the script and the beacon from your own domain, so ad blockers see a first-party request. Your proxy forwards two paths to Nordtally and adds a secret, so Nordtally can trust the visitor address it passes on.

Your domainForwarded to
GET /stats/s.jshttps://in.nordtally.com/s.js
POST /stats/ehttps://in.nordtally.com/api/event

Set up

  • In the dashboard, open the site, then Install → First-party proxy and create a proxy secret (ntp_…). Store it as a secret on your proxy, never in version control.
  • Deploy one of the templates below.
  • Load the script from your own domain:
index.html
<script defer data-site="YOUR_SITE_ID" data-api="/stats/e" src="/stats/s.js"></script>

The proxy sends X-Nordtally-Proxy-Secret and X-Nordtally-Client-IP. Without a matching secret the client address header is ignored, so nobody can choose the address their events count under. A wrong secret drops the beacon and shows up in the site’s compliance centre. All templates forward only the headers Nordtally needs; your cookies, Authorization and Referer never reach us.

Cloudflare Worker

Route shop.example/stats/* to the Worker and set the secret with wrangler secret put NORDTALLY_PROXY_SECRET.

worker.ts
// Nordtally first-party proxy for Cloudflare Workers.
//
// Serves the tracker at /stats/s.js and forwards its beacons from /stats/e on your own domain to
// Nordtally ingest, adding your site's proxy secret and the visitor's IP so visitors are counted
// correctly. Optionally ("pure edge" mode) it counts HTML page loads itself, with no script at all.
//
// Settings (wrangler.toml [vars] / `wrangler secret put`):
//   NORDTALLY_PROXY_SECRET  required, a secret: the site's proxy secret (ntp_...)
//   NORDTALLY_SITE          the site id from your snippet (data-site); required for pure edge mode
//   NORDTALLY_EDGE_PAGEVIEWS  "true" to count page loads at the edge (don't also load the script)
//   NORDTALLY_PATH          default "/stats"
//   NORDTALLY_INGEST        default "https://in.nordtally.com"
//
// Nothing here logs or stores the visitor's IP or User-Agent; they are only passed on to ingest,
// which uses them in memory for the daily visitor hash, country and device type.

export interface Env {
  NORDTALLY_PROXY_SECRET: string;
  NORDTALLY_SITE?: string;
  NORDTALLY_EDGE_PAGEVIEWS?: string;
  NORDTALLY_PATH?: string;
  NORDTALLY_INGEST?: string;
}

interface Context {
  waitUntil(promise: Promise<unknown>): void;
}

const SECRET_HEADER = 'X-Nordtally-Proxy-Secret';
const CLIENT_IP_HEADER = 'X-Nordtally-Client-IP';
// Only what ingest needs is forwarded: never cookies, authorization or other site headers.
const FORWARDED = [
  'content-type',
  'user-agent',
  'sec-gpc',
  'dnt',
  'purpose',
  'sec-purpose',
  'x-purpose',
  'x-moz',
];
const TRACKER = /^\/s(\.[0-9a-f]+)?\.js$/;

function settings(env: Env) {
  return {
    prefix: (env.NORDTALLY_PATH ?? '/stats').replace(/\/+$/, ''),
    ingest: (env.NORDTALLY_INGEST ?? 'https://in.nordtally.com').replace(/\/+$/, ''),
  };
}

function ingestHeaders(request: Request, env: Env): Headers {
  const h = new Headers();
  for (const name of FORWARDED) {
    const v = request.headers.get(name);
    if (v !== null) h.set(name, v);
  }
  h.set(SECRET_HEADER, env.NORDTALLY_PROXY_SECRET);
  // Set by Cloudflare from the visitor's connection, not taken from the client.
  const ip = request.headers.get('CF-Connecting-IP');
  if (ip) h.set(CLIENT_IP_HEADER, ip);
  return h;
}

async function proxyTracker(request: Request, ingest: string, file: string): Promise<Response> {
  const headers = new Headers();
  const etag = request.headers.get('if-none-match');
  if (etag) headers.set('if-none-match', etag);
  const res = await fetch(`${ingest}${file}`, { headers });
  return new Response(res.body, res);
}

async function proxyBeacon(request: Request, env: Env, ingest: string): Promise<Response> {
  const res = await fetch(`${ingest}/api/event`, {
    method: 'POST',
    headers: ingestHeaders(request, env),
    body: await request.arrayBuffer(),
  });
  // Ingest always answers 202 with an empty body; pass that through unchanged.
  return new Response(null, { status: res.status, headers: { 'cache-control': 'no-store' } });
}

const SPECULATIVE = /prefetch|prerender|preview/i;
const FILE_EXTENSION = /\/[^/]*\.(?!html?$)[a-z0-9]{1,8}$/i;

/** A real HTML page load (not a prefetch, an asset, an API call or a framework data request). */
export function isPageLoad(request: Request, prefix: string): boolean {
  if (request.method !== 'GET') return false;
  const h = request.headers;
  for (const name of ['purpose', 'sec-purpose', 'x-purpose', 'x-moz']) {
    if (SPECULATIVE.test(h.get(name) ?? '')) return false;
  }
  if (h.has('next-router-prefetch') || h.has('rsc')) return false;
  if (!(h.get('accept') ?? '').includes('text/html')) return false;
  const dest = h.get('sec-fetch-dest');
  if (dest && dest !== 'document') return false;
  const url = new URL(request.url);
  const path = url.pathname;
  if (path === prefix || path.startsWith(`${prefix}/`)) return false;
  if (url.searchParams.has('_rsc') || path.startsWith('/_next/')) return false;
  return !FILE_EXTENSION.test(path);
}

function isHtmlResponse(res: Response): boolean {
  if (res.status === 304) return true;
  return res.ok && (res.headers.get('content-type') ?? '').includes('text/html');
}

function sendPageview(request: Request, env: Env, ingest: string): Promise<unknown> {
  const headers = ingestHeaders(request, env);
  headers.set('content-type', 'text/plain;charset=UTF-8');
  const body = JSON.stringify({
    s: env.NORDTALLY_SITE,
    n: 'pageview',
    u: request.url,
    r: request.headers.get('referer'),
  });
  return fetch(`${ingest}/api/event`, { method: 'POST', headers, body }).catch(() => {});
}

export async function handle(request: Request, env: Env, ctx: Context): Promise<Response> {
  const { prefix, ingest } = settings(env);
  const url = new URL(request.url);

  if (url.pathname.startsWith(`${prefix}/`)) {
    const rest = url.pathname.slice(prefix.length);
    if (request.method === 'GET' && TRACKER.test(rest)) return proxyTracker(request, ingest, rest);
    if (request.method === 'POST' && rest === '/e') return proxyBeacon(request, env, ingest);
    return new Response('Not found', { status: 404 });
  }

  // Everything else goes to your origin untouched.
  const response = await fetch(request);
  if (env.NORDTALLY_EDGE_PAGEVIEWS === 'true' && env.NORDTALLY_SITE) {
    if (isPageLoad(request, prefix) && isHtmlResponse(response)) {
      ctx.waitUntil(sendPageview(request, env, ingest));
    }
  }
  return response;
}

export default {
  fetch: (request: Request, env: Env, ctx: Context) => handle(request, env, ctx),
};
wrangler.toml
# Example Wrangler config. Replace the zone and site id, then:
#   npx wrangler secret put NORDTALLY_PROXY_SECRET
#   npx wrangler deploy
name = "nordtally-proxy"
main = "worker.ts"
compatibility_date = "2026-11-01"

# Proxy only: route just the /stats/ paths through the Worker.
routes = [{ pattern = "shop.example/stats/*", zone_name = "shop.example" }]

# Pure edge mode: route every page instead (and remove the script tag from your pages).
# routes = [{ pattern = "shop.example/*", zone_name = "shop.example" }]

[vars]
NORDTALLY_SITE = "nt_yoursiteid"
NORDTALLY_EDGE_PAGEVIEWS = "false"
# NORDTALLY_PATH = "/stats"
# NORDTALLY_INGEST = "https://in.nordtally.com"

bunny.net Edge Script

Attach the script to your pull zone as middleware and add the secret under Secrets.

script.ts
// Nordtally first-party proxy for bunny.net Edge Scripting (a middleware script on the pull zone
// that serves your site).
//
// Serves the tracker at /stats/s.js and forwards its beacons from /stats/e to Nordtally ingest,
// adding your site's proxy secret and the visitor's IP. Optionally ("pure edge" mode) it counts
// HTML page loads itself, with no script at all.
//
// Settings (Edge Script → Env Configuration; the proxy secret as a Secret):
//   NORDTALLY_PROXY_SECRET     required: the site's proxy secret (ntp_...)
//   ORIGIN_URL                 required: your pull zone's origin, e.g. https://origin.shop.example
//   NORDTALLY_SITE             the site id from your snippet (data-site); needed for pure edge mode
//   NORDTALLY_EDGE_PAGEVIEWS   "true" to count page loads at the edge (don't also load the script)
//   NORDTALLY_CLIENT_IP_HEADER default "x-real-ip" (bunny.net's visitor address header)
//   NORDTALLY_PATH             default "/stats"
//   NORDTALLY_INGEST           default "https://in.nordtally.com"
//
// Nothing here logs or stores the visitor's IP or User-Agent; they are only passed on to ingest,
// which uses them in memory for the daily visitor hash, country and device type.

import process from 'node:process';
import * as BunnySDK from '@bunny.net/edgescript-sdk@0.13.0';

const SECRET_HEADER = 'X-Nordtally-Proxy-Secret';
const CLIENT_IP_HEADER = 'X-Nordtally-Client-IP';
// Only what ingest needs is forwarded: never cookies, authorization or other site headers.
const FORWARDED = [
  'content-type',
  'user-agent',
  'sec-gpc',
  'dnt',
  'purpose',
  'sec-purpose',
  'x-purpose',
  'x-moz',
];
const TRACKER = /^\/s(\.[0-9a-f]+)?\.js$/;
const SPECULATIVE = /prefetch|prerender|preview/i;
const FILE_EXTENSION = /\/[^/]*\.(?!html?$)[a-z0-9]{1,8}$/i;

declare const Bunny: { v1?: { waitUntil?: (promise: Promise<unknown>) => void } } | undefined;

function env(name: string, fallback = ''): string {
  return process.env[name] ?? fallback;
}

function settings() {
  return {
    prefix: env('NORDTALLY_PATH', '/stats').replace(/\/+$/, ''),
    ingest: env('NORDTALLY_INGEST', 'https://in.nordtally.com').replace(/\/+$/, ''),
  };
}

function ingestHeaders(request: Request): Headers {
  const h = new Headers();
  for (const name of FORWARDED) {
    const v = request.headers.get(name);
    if (v !== null) h.set(name, v);
  }
  h.set(SECRET_HEADER, env('NORDTALLY_PROXY_SECRET'));
  // bunny.net documents X-Real-IP as the visitor's address, replacing any client-sent value. The
  // README explains how to check that it reaches the script on your pull zone.
  const ip = request.headers.get(env('NORDTALLY_CLIENT_IP_HEADER', 'x-real-ip'))?.trim();
  if (ip) h.set(CLIENT_IP_HEADER, ip);
  return h;
}

/** A real HTML page load (not a prefetch, an asset, an API call or a framework data request). */
export function isPageLoad(request: Request, prefix: string): boolean {
  if (request.method !== 'GET') return false;
  const h = request.headers;
  for (const name of ['purpose', 'sec-purpose', 'x-purpose', 'x-moz']) {
    if (SPECULATIVE.test(h.get(name) ?? '')) return false;
  }
  if (h.has('next-router-prefetch') || h.has('rsc')) return false;
  if (!(h.get('accept') ?? '').includes('text/html')) return false;
  const dest = h.get('sec-fetch-dest');
  if (dest && dest !== 'document') return false;
  const url = new URL(request.url);
  const path = url.pathname;
  if (path === prefix || path.startsWith(`${prefix}/`)) return false;
  if (url.searchParams.has('_rsc') || path.startsWith('/_next/')) return false;
  return !FILE_EXTENSION.test(path);
}

/** Answers /stats/* requests; anything else returns undefined and continues to cache/origin. */
export async function handleProxy(request: Request): Promise<Response | undefined> {
  const { prefix, ingest } = settings();
  const url = new URL(request.url);
  if (!url.pathname.startsWith(`${prefix}/`)) return undefined;
  const rest = url.pathname.slice(prefix.length);

  if (request.method === 'GET' && TRACKER.test(rest)) {
    const headers = new Headers();
    const etag = request.headers.get('if-none-match');
    if (etag) headers.set('if-none-match', etag);
    const res = await fetch(`${ingest}${rest}`, { headers });
    return new Response(res.body, res);
  }
  if (request.method === 'POST' && rest === '/e') {
    const res = await fetch(`${ingest}/api/event`, {
      method: 'POST',
      headers: ingestHeaders(request),
      body: await request.arrayBuffer(),
    });
    return new Response(null, { status: res.status, headers: { 'cache-control': 'no-store' } });
  }
  return new Response('Not found', { status: 404 });
}

/** Pure edge mode: queue a pageview for a page load answered with HTML. */
export function trackResponse(request: Request, response: Response): Promise<unknown> | undefined {
  const site = env('NORDTALLY_SITE');
  if (env('NORDTALLY_EDGE_PAGEVIEWS') !== 'true' || !site) return undefined;
  const { prefix, ingest } = settings();
  if (!isPageLoad(request, prefix)) return undefined;
  const html =
    response.status === 304 ||
    (response.ok && (response.headers.get('content-type') ?? '').includes('text/html'));
  if (!html) return undefined;

  const headers = ingestHeaders(request);
  headers.set('content-type', 'text/plain;charset=UTF-8');
  const body = JSON.stringify({
    s: site,
    n: 'pageview',
    u: request.url,
    r: request.headers.get('referer'),
  });
  const sent = fetch(`${ingest}/api/event`, { method: 'POST', headers, body }).catch(() => {});
  // Keep the isolate alive until the pageview is sent, without delaying the page.
  if (typeof Bunny !== 'undefined') Bunny.v1?.waitUntil?.(sent);
  return sent;
}

BunnySDK.net.http
  .servePullZone({ url: env('ORIGIN_URL') })
  .onClientRequest(
    async (ctx: { request: Request }) => (await handleProxy(ctx.request)) ?? ctx.request,
  )
  .onClientResponse(async (ctx: { request: Request; response: Response }) => {
    trackResponse(ctx.request, ctx.response);
    return ctx.response;
  });

Nginx

Include this in the server block of your site.

nordtally.conf
# Nordtally first-party proxy for Nginx. Include it inside your site's `server { }` block:
#
#     include /etc/nginx/snippets/nordtally.conf;
#
# Replace ntp_REPLACE_ME with your site's proxy secret and keep this file readable by root only.
# Then load the tracker from your own domain:
#
#     <script defer src="/stats/s.js" data-site="nt_yoursiteid" data-api="/stats/e"></script>
#
# $remote_addr must be the visitor's address. If this Nginx sits behind a CDN or load balancer,
# set that up first with the realip module (set_real_ip_from + real_ip_header), or every visitor
# is counted from the CDN's address.

# The tracker script, including versioned /stats/s.<hash>.js URLs.
location ~ ^/stats/s(\.[0-9a-f]+)?\.js$ {
    limit_except GET { deny all; }
    access_log off;
    rewrite ^/stats/(.*)$ /$1 break;
    proxy_pass https://in.nordtally.com;
    proxy_ssl_server_name on;
    # Forward none of the visitor's headers except the cache validator.
    proxy_pass_request_headers off;
    proxy_set_header Host in.nordtally.com;
    proxy_set_header If-None-Match $http_if_none_match;
}

# Beacons. Nordtally trusts X-Nordtally-Client-IP only together with the proxy secret.
location = /stats/e {
    limit_except POST { deny all; }
    # The visitor's IP and User-Agent pass through here; don't write them to your own logs either.
    access_log off;
    client_max_body_size 8k;
    proxy_pass https://in.nordtally.com/api/event;
    proxy_ssl_server_name on;
    # Forward only what ingest needs. Cookies, credentials, Referer, client hints and
    # X-Forwarded-* never reach Nordtally. Headers the visitor didn't send stay unset.
    proxy_pass_request_headers off;
    proxy_set_header Host in.nordtally.com;
    proxy_set_header X-Nordtally-Proxy-Secret "ntp_REPLACE_ME";
    proxy_set_header X-Nordtally-Client-IP $remote_addr;
    proxy_set_header Content-Type $content_type;
    proxy_set_header User-Agent $http_user_agent;
    proxy_set_header Sec-GPC $http_sec_gpc;
    proxy_set_header DNT $http_dnt;
    # Prefetch and prerender markers, so speculative loads are filtered.
    proxy_set_header Purpose $http_purpose;
    proxy_set_header Sec-Purpose $http_sec_purpose;
    proxy_set_header X-Purpose $http_x_purpose;
    proxy_set_header X-Moz $http_x_moz;
}

Caddy

Set NORDTALLY_PROXY_SECRET in Caddy’s environment.

Caddyfile
# Nordtally first-party proxy for Caddy. Copy the two `handle` blocks into your site block, set
# NORDTALLY_PROXY_SECRET in Caddy's environment, and load the tracker from your own domain:
#
#     <script defer src="/stats/s.js" data-site="nt_yoursiteid" data-api="/stats/e"></script>
#
# {client_ip} is the visitor's address. If Caddy sits behind a CDN or load balancer, configure
# `trusted_proxies` in the global options so it is not the CDN's address.

shop.example {
	# The tracker script, including versioned /stats/s.<hash>.js URLs.
	@nordtally_script path_regexp ^/stats/s(\.[0-9a-f]+)?\.js$
	handle @nordtally_script {
		uri strip_prefix /stats
		reverse_proxy https://in.nordtally.com {
			# Forward none of the visitor's headers except the cache validator.
			header_up -*
			header_up If-None-Match {http.request.header.If-None-Match}
		}
	}

	# Beacons. Nordtally trusts X-Nordtally-Client-IP only together with the proxy secret.
	@nordtally_beacon {
		method POST
		path /stats/e
	}
	handle @nordtally_beacon {
		request_body {
			max_size 8KB
		}
		rewrite * /api/event
		reverse_proxy https://in.nordtally.com {
			# Forward only what ingest needs. `-*` runs before the lines below, so cookies,
			# credentials, Referer, client hints and X-Forwarded-* never reach Nordtally.
			header_up -*
			header_up X-Nordtally-Proxy-Secret {env.NORDTALLY_PROXY_SECRET}
			header_up X-Nordtally-Client-IP {client_ip}
			header_up Content-Type {http.request.header.Content-Type}
			header_up User-Agent {http.request.header.User-Agent}
			header_up Sec-GPC {http.request.header.Sec-GPC}
			header_up DNT {http.request.header.DNT}
			# Prefetch and prerender markers, so speculative loads are filtered.
			header_up Purpose {http.request.header.Purpose}
			header_up Sec-Purpose {http.request.header.Sec-Purpose}
			header_up X-Purpose {http.request.header.X-Purpose}
			header_up X-Moz {http.request.header.X-Moz}
		}
	}

	# Your site, unchanged.
	handle {
		reverse_proxy localhost:3000
	}
}

Note: when you use Cloudflare or bunny.net, that provider handles your visitors’ requests too. Mention it in your privacy policy.