1. Overview
Adblockers and content filters block hundreds of analytics domains by pattern: anything that looks like collect., track., or matches a public hostname on the EasyPrivacy list. If your SDK calls collect.leatmap.com directly, somewhere between 15% and 35% of your visitors will silently drop events with zero error feedback.
A first-party proxy fixes this by forwarding ingest through your own origin under a path you choose — for example example.com/e/*. The browser sees a same-origin request to your domain, adblocker domain-pattern filters do not match, and the events land at the collector.
This page ships four copy-paste templates. Pick the one that matches where your traffic terminates today. Pair each with the SDK option collectorPath: '/e' (shipped in TRK-258).
2. How it works
Your proxy receives the visitor’s browser request, determines the real visitor IP (from the TCP peer or from whatever X-Forwarded-For chain your CDN gives you), and forwards the request to collect.leatmap.com with five extra headers:
X-Forwarded-For: <visitor-ip>— the single, trusted, leftmost IP. The collector reads only this entry; any rightward proxy-chain entries are ignored.X-Site-Id: <uuid>— the UUID of the site that minted the shared secret. Find this in your dashboard.X-Leatmap-Signature: <hex>— lowercase-hex HMAC-SHA256 of the freshness-bound payload described below, computed with yourLEATMAP_PROXY_SECRETas the key. Exactly 64 hex characters.X-Leatmap-Timestamp: <ms-since-epoch>— decimal milliseconds. The collector rejects anything older than 60 seconds and tolerates up to 5 seconds of future-direction NTP skew on your proxy.X-Leatmap-Nonce: <32-hex>— 16 bytes from your platform’s CSPRNG, lowercase hex. The collector rejects any nonce it has already seen for this site within the freshness window so a captured envelope cannot be replayed.
The collector validates the signature in constant time against the per-site secret it stores encrypted at rest, then runs the timestamp + nonce gates. A valid envelope trusts your forwarded IP. A missing or mismatched signature falls back to the direct-TCP peer IP the collector observes (so a misconfigured proxy never breaks ingest entirely — it just downgrades the ingest mode for that request from first_party_proxy to direct).
The HMAC scheme is byte-exact:
- algorithm: HMAC-SHA256;
- key: the UTF-8 bytes of
LEATMAP_PROXY_SECRET(no base64 unwrap, no hex decode); - payload: the UTF-8 bytes of
ip_string || "|" || ts_ms || "|" || nonce_hex— for example203.0.113.5|1700000000000|00112233445566778899aabbccddeeff. The separator is a single ASCII pipe. - output: lowercase hex, 64 characters.
3. The LEATMAP_PROXY_SECRET convention
Pair it with NEXT_PUBLIC_LEATMAP_SITE_ID (or its equivalent on your platform) for the site UUID. The site ID is not secret — it ships to the browser as part of the SDK config — but keeping it as an env var keeps the templates copy-pasteable across workspaces.
4. Path naming guidance
The whole point of proxying is that adblocker domain-pattern filters do not match the path. Pick the path carefully.
Never use any of these substrings in the subpath the SDK calls:
analyticstrackingtrackertracktelemetrystatsstatmetricscollectpageviewingestbeaconpixelplausibleposthogleatmap
Every one of those is on at least one major filter list (EasyPrivacy, uBlock Origin’s built-in, AdGuard tracking-protection). Using them defeats the entire purpose of the proxy.
Recommended subpaths that are short, neutral, and on no public list as of this writing:
/e//api/v1/m/internal/m/ph/
/e/ is short and ambiguous; /api/v1/m and /internal/m look like internal tooling; /ph/ is short and arbitrary.
Once you pick a path, set the SDK’s collectorPath option to match. The templates on this page assume /e/; rename the matcher / route / rewrite source as appropriate.
5. Next.js 16 — proxy.ts (recommended)
Next.js 16 renamed middleware.ts to proxy.ts. This variant is the recommended path for most customers: it signs every forwarded request with HMAC, runs on the Edge / Node runtime depending on your deployment, and uses crypto.subtle so there is no Node-only dependency to bundle.
// proxy.ts — Next.js 16 (replaces middleware.ts).
// Drop into the root of your Next.js app, alongside next.config.ts.
// Requires LEATMAP_PROXY_SECRET and NEXT_PUBLIC_LEATMAP_SITE_ID in env.
import { NextResponse, type NextRequest } from 'next/server';
export const config = {
matcher: ['/e/:path*'],
};
const COLLECTOR_ORIGIN = 'https://collect.leatmap.com';
function toHex(buffer: ArrayBuffer | Uint8Array): string {
const bytes =
buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let out = '';
for (let i = 0; i < bytes.length; i++) {
out += bytes[i].toString(16).padStart(2, '0');
}
return out;
}
// Sign the freshness-bound payload ip || "|" || ts_ms || "|" || nonce_hex.
// Matched verbatim by the collector verifier.
async function signPayload(
secret: string,
ip: string,
tsMs: number,
nonceHex: string,
): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const payload = `${ip}|${tsMs}|${nonceHex}`;
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload));
return toHex(sig);
}
function clientIpFromRequest(request: NextRequest): string {
const xff = request.headers.get('x-forwarded-for');
if (xff) {
const leftmost = xff.split(',')[0]?.trim();
if (leftmost) return leftmost;
}
const real = request.headers.get('x-real-ip');
if (real) return real.trim();
return '0.0.0.0';
}
function randomNonceHex(): string {
// 16 bytes = 128 bits of entropy = 32 lowercase-hex chars.
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return toHex(bytes);
}
export async function proxy(request: NextRequest) {
const secret = process.env.LEATMAP_PROXY_SECRET;
const siteId = process.env.NEXT_PUBLIC_LEATMAP_SITE_ID;
if (!secret || !siteId) {
return new NextResponse('proxy not configured', { status: 500 });
}
const ip = clientIpFromRequest(request);
const tsMs = Date.now();
const nonceHex = randomNonceHex();
const signature = await signPayload(secret, ip, tsMs, nonceHex);
const incoming = new URL(request.url);
const target = new URL(COLLECTOR_ORIGIN);
// Map /e/<rest> on your origin to /<rest> on the collector.
target.pathname = incoming.pathname.replace(/^\/e/, '') || '/';
target.search = incoming.search;
const headers = new Headers(request.headers);
headers.set('host', target.host);
headers.set('x-forwarded-for', ip);
headers.set('x-site-id', siteId);
headers.set('x-leatmap-signature', signature);
headers.set('x-leatmap-timestamp', String(tsMs));
headers.set('x-leatmap-nonce', nonceHex);
return NextResponse.rewrite(target, { request: { headers } });
}
6. Next.js 16 — next.config.ts rewrites
If you do not want a proxy.tson the request path — for example, because you already have one doing other work and do not want to take ownership of the visitor-IP determination — Next.js’s static rewrites do the same-origin path swap with no code.
// next.config.ts — Next.js 16, no-code variant.
// This variant CANNOT sign HMAC. The collector falls back to the
// direct-TCP IP it observes (Vercel / your CDN already gives it the
// visitor IP through their own X-Forwarded-For chain). You still get
// adblocker bypass via the same-origin path, but ingest mode will be
// reported as "direct" rather than "first_party_proxy".
//
// Use proxy.ts above if you want signed forwarding and the
// "first_party_proxy" coverage badge.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: '/e/:path*',
destination: 'https://collect.leatmap.com/:path*',
},
];
},
};
export default nextConfig;
7. Cloudflare Worker
Run a Cloudflare Worker on a route attached to your domain. Use this if your site is fronted by Cloudflare and you do not want to deploy Next.js middleware — the worker signs every request before forwarding to the collector.
Save the worker source as worker.ts and the wrangler config as wrangler.toml, then deploy with wrangler deploy.
// worker.ts — Cloudflare Worker.
// Bind the worker to a route on your domain (e.g.
// "example.com/e/*"). Set LEATMAP_PROXY_SECRET and LEATMAP_SITE_ID
// via "wrangler secret put" / dashboard.
export interface Env {
LEATMAP_PROXY_SECRET: string;
LEATMAP_SITE_ID: string;
}
const COLLECTOR_ORIGIN = 'https://collect.leatmap.com';
function toHex(buffer: ArrayBuffer | Uint8Array): string {
const bytes =
buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let out = '';
for (let i = 0; i < bytes.length; i++) {
out += bytes[i].toString(16).padStart(2, '0');
}
return out;
}
async function signPayload(
secret: string,
ip: string,
tsMs: number,
nonceHex: string,
): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const payload = `${ip}|${tsMs}|${nonceHex}`;
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload));
return toHex(sig);
}
function randomNonceHex(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return toHex(bytes);
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const ip =
request.headers.get('cf-connecting-ip') ??
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
'0.0.0.0';
const tsMs = Date.now();
const nonceHex = randomNonceHex();
const signature = await signPayload(
env.LEATMAP_PROXY_SECRET,
ip,
tsMs,
nonceHex,
);
const incoming = new URL(request.url);
const target = new URL(COLLECTOR_ORIGIN);
target.pathname = incoming.pathname.replace(/^\/e/, '') || '/';
target.search = incoming.search;
const headers = new Headers(request.headers);
headers.set('host', target.host);
headers.set('x-forwarded-for', ip);
headers.set('x-site-id', env.LEATMAP_SITE_ID);
headers.set('x-leatmap-signature', signature);
headers.set('x-leatmap-timestamp', String(tsMs));
headers.set('x-leatmap-nonce', nonceHex);
return fetch(target.toString(), {
method: request.method,
headers,
body: request.body,
redirect: 'manual',
});
},
};
# wrangler.toml — Cloudflare Worker config.
# Run: wrangler secret put LEATMAP_PROXY_SECRET
# Run: wrangler secret put LEATMAP_SITE_ID
name = "leatmap-proxy"
main = "worker.ts"
compatibility_date = "2026-05-13"
# Bind the worker to the path you chose on your own domain.
# Pair this with the SDK option collectorPath: "/e".
routes = [
{ pattern = "example.com/e/*", zone_name = "example.com" },
]
8. Caddy & Nginx
If your traffic terminates at a self-hosted reverse proxy, these two snippets cover the most common cases. Nginx with the Lua module signs every request; Caddy on its own does not have an HMAC primitive in its rewrite pipeline, so the Caddy snippet documents the unsigned fallback.
Nginx (signed)
Requires ngx_http_lua_module. OpenResty ships it by default; on stock nginx, install libnginx-mod-http-lua on Debian/Ubuntu or build with --add-module=ngx_devel_kit --add-module=ngx_lua. Also install the lua-resty-hmac and lua-resty-string libraries (available on opm/luarocks).
# nginx.conf — requires ngx_http_lua_module
# (OpenResty ships it by default; on stock nginx install
# libnginx-mod-http-lua or build with --add-module=ngx_devel_kit
# + ngx_lua).
#
# Set LEATMAP_PROXY_SECRET and LEATMAP_SITE_ID in the environment
# of the nginx master process (e.g. systemd EnvironmentFile).
env LEATMAP_PROXY_SECRET;
env LEATMAP_SITE_ID;
http {
lua_shared_dict leatmap_cfg 1m;
init_by_lua_block {
local secret = os.getenv("LEATMAP_PROXY_SECRET")
local site_id = os.getenv("LEATMAP_SITE_ID")
ngx.shared.leatmap_cfg:set("secret", secret or "")
ngx.shared.leatmap_cfg:set("site_id", site_id or "")
}
server {
listen 443 ssl http2;
server_name example.com;
location /e/ {
access_by_lua_block {
local resty_hmac = require "resty.hmac"
local resty_str = require "resty.string"
local resty_random = require "resty.random"
local ip = ngx.var.remote_addr
local cfg = ngx.shared.leatmap_cfg
local secret = cfg:get("secret")
local site_id = cfg:get("site_id")
if not secret or secret == "" then
return ngx.exit(500)
end
-- TRK-265: freshness-bound payload.
local ts_ms = tostring(math.floor(ngx.now() * 1000))
local nonce_bytes = resty_random.bytes(16)
local nonce_hex = resty_str.to_hex(nonce_bytes)
local payload = ip .. "|" .. ts_ms .. "|" .. nonce_hex
local hmac = resty_hmac:new(secret, resty_hmac.ALGOS.SHA256)
hmac:update(payload)
local digest = hmac:final()
local sig = resty_str.to_hex(digest)
ngx.req.set_header("X-Forwarded-For", ip)
ngx.req.set_header("X-Site-Id", site_id)
ngx.req.set_header("X-Leatmap-Signature", sig)
ngx.req.set_header("X-Leatmap-Timestamp", ts_ms)
ngx.req.set_header("X-Leatmap-Nonce", nonce_hex)
}
rewrite ^/e/(.*)$ /$1 break;
proxy_pass https://collect.leatmap.com;
proxy_set_header Host collect.leatmap.com;
proxy_ssl_server_name on;
}
}
}
Caddy (unsigned fallback)
# Caddyfile — Caddy v2.
# Caddy's native directives do not expose an HMAC primitive in the
# request-rewrite pipeline. To sign per-request you need either the
# caddy-ext/replace-response plugin or a small caddy plugin written
# in Go.
#
# This snippet documents the UNSIGNED forwarding path — same fallback
# posture as the Next.js next.config.ts variant above. You still get
# adblocker bypass via the same-origin path; ingest mode will be
# reported as "direct".
#
# If you need signed forwarding from Caddy today, run a Cloudflare
# Worker in front of Caddy (template 7) or terminate the proxy in
# your app server (template 5) and let Caddy reverse-proxy to that.
example.com {
handle_path /e/* {
reverse_proxy https://collect.leatmap.com {
header_up Host collect.leatmap.com
# Caddy preserves the visitor IP in X-Forwarded-For
# automatically. No HMAC; the collector observes the
# direct TCP peer and falls back per TRK-259.
}
}
}
9. Verifying the proxy is working
Once the proxy is deployed and the SDK is configured withcollectorPath, open your site in a regular (non-incognito) browser with your usual adblocker enabled and watch the network tab:
- Requests should go to your domain at the path you chose (e.g.
example.com/e/events), not tocollect.leatmap.com. - Response status should be
204(the collector’s standard success). - In the dashboard’s coverage page (TRK-262), the ingest-mode column should read
first_party_proxyfor signed templates anddirectfor the unsigned fallbacks.
If the collector rejects the request, the response body carries a stable error code. The most common ones:
| Code | What it means |
|---|---|
proxy_signature_malformed | The X-Leatmap-Signature header is not 64 lowercase-hex characters. Usually means the HMAC output was base64-encoded, uppercased, or truncated. |
proxy_signature_missing_xff | The signature header is present but X-Forwarded-For is not. The collector cannot verify the signature without the IP it was computed against. |
proxy_signature_unknown_site | The X-Site-Id value does not match any provisioned proxy secret. Re-mint the secret from the dashboard, redeploy with the new value. |
proxy_signature_replay_stale | The X-Leatmap-Timestamp is more than 60 seconds in the past (or more than 5 seconds in the future). Usually means your proxy host clock has drifted. Install NTP / chrony if it is not already running. |
proxy_signature_replay_nonce | The X-Leatmap-Nonce value was already seen for this site within the freshness window. Means the same signed envelope was replayed. Usually a bug in your sign path emitting a constant nonce rather than a fresh CSPRNG sample. |
proxy_signature_legacy_format | X-Leatmap-Signature is present but both X-Leatmap-Timestamp and X-Leatmap-Nonce are absent. This is the v3.0 IP-only payload shape, which no longer verifies. Upgrade your proxy template to the v3.1 shape. |
proxy_signature_partial_replay_headers | Only one of (X-Leatmap-Timestamp, X-Leatmap-Nonce) shipped. Both are required for the v3.1 signed-payload shape. |
On proxy_signature_malformed / proxy_signature_missing_xff, the most common cause is signing the wrong string — make sure the HMAC payload is the IP UTF-8 bytes, not a JSON object, and not base64-encoded.
10. What this does and does not bypass
What proxying bypasses. Adblocker domain-pattern filters (EasyPrivacy, AdGuard tracking-protection, uBlock Origin’s built-in). Browser third-party-cookie blocking is irrelevant here — leatmap does not set cross-site cookies. CDN-edge blocking based on the SDK’s default hostname.
What proxying does NOT bypass.
- DNS-level blockers — Pi-hole, NextDNS, AdGuard DNS, Cloudflare Family DNS. These resolve the domain at lookup time and never see the path. If a visitor uses one of these and your domain is on its blocklist for some other reason, the request never leaves their network. Proxying through your origin does not help here; nothing can.
- Browser tracking-protection featuresthat look at request headers or fingerprint patterns rather than domain names. Brave’s Shields and Firefox’s ETP mostly use domain lists today, but their behavior evolves.
- Consent enforcement. The SDK respects your consent banner and the user’s opt-out exactly as it did before. Proxying is a transport detail; it does not change which events are sent or whether they are sent at all. If a visitor declines analytics on your banner, no events go through the proxy.
- Server-side enforcement. The collector still enforces every rate limit, plan quota, and consent check the direct path enforces. Proxying does not unlock higher volumes or relax any policy.
Realistic recovery: in a customer pilot, a Next.js proxy.tsdeployment recovered 22% of previously dropped events. Your number depends on your audience’s adblocker mix.