Skip to main content

Docs

First-party proxy templates

Forward leatmap ingest through your own origin so adblockers and domain-pattern filters do not silently drop events. Copy-paste templates for Next.js, Cloudflare Workers, Caddy, and Nginx.

Last updated:

On this page

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 your LEATMAP_PROXY_SECRET as 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 example 203.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:

  • analytics
  • tracking
  • tracker
  • track
  • telemetry
  • stats
  • stat
  • metrics
  • collect
  • pageview
  • ingest
  • beacon
  • pixel
  • plausible
  • posthog
  • leatmap

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.tsTypeScript
// 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.tsTypeScript
// 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.tsTypeScript
// 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.tomlTOML
# 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.confNginx
# 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)

CaddyfileCaddyfile
# 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 to collect.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_proxy for signed templates and direct for the unsigned fallbacks.

If the collector rejects the request, the response body carries a stable error code. The most common ones:

CodeWhat it means
proxy_signature_malformedThe X-Leatmap-Signature header is not 64 lowercase-hex characters. Usually means the HMAC output was base64-encoded, uppercased, or truncated.
proxy_signature_missing_xffThe 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_siteThe 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_staleThe 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_nonceThe 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_formatX-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_headersOnly 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.