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 domain | Forwarded to |
|---|---|
GET /stats/s.js | https://in.nordtally.com/s.js |
POST /stats/e | https://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:
<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.
// 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),
};
# 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.
// 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 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.
# 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.