Why Admaxxer's Geo Card Shows 'Unknown' for Every Visitor

TL;DR: Geo enrichment is live for new traffic from 2026-05-06. Every public IP now resolves to a country code via a local geoip-lite database lookup. No IPs leave our infrastructure — the same privacy model used by Plausible and Fathom. Historical rows are not backfilled (we don’t persist raw IPs), so pre-2026-05-06 country data stays as Unknown. New rows from now on get a country.

Why your map shows “Unknown”

Admaxxer’s pixel ingest used to read the visitor’s country from a single header: cf-ipcountry. That header is set automatically by Cloudflare’s edge network when traffic flows through Cloudflare on its way to our ingest endpoint — no extra work required, two-letter ISO country code arrives in the request.

But Admaxxer’s ingest endpoint runs on a bare-metal Hetzner box in Hillsboro, Oregon, with no Cloudflare proxy in front. For customer traffic that doesn’t pass through Cloudflare’s edge on its way to /api/event, the cf-ipcountry header was never set. The pixel row still wrote successfully — everything else worked — but country came in as the empty string. Downstream, the Geo card on /dashboard rendered every row as “Unknown,” making the card effectively useless for affected workspaces.

This isn’t a customer-side issue — it’s a server-side gap on our end. Whether the customer is on Cloudflare, Vercel, Netlify, AWS CloudFront, or no CDN at all, the pixel call ends up at our ingest endpoint and we shouldn’t have been depending on a single CDN-set header. Now we don’t.

What we changed (2026-05-06)

Added server/lib/geo/resolveCountry.ts — a deterministic resolution chain that runs on every /api/event request. Tries cf-ipcountry first (still preferred when the customer’s site is behind Cloudflare — the edge is more accurate than IP databases for VPN/proxy detection), falls through to other CDN-set country headers, then falls back to a local IP-to-country lookup using the geoip-lite npm package — the same library used by Plausible, Fathom, and most privacy-first analytics tools. As long as the IP is public and known to MaxMind’s GeoLite2 database, every visitor now gets a country.

Resolution chain

On every ingest request, the server walks this chain in order. The first non-empty result wins:

  1. cf-ipcountry header — set by Cloudflare’s edge when traffic passes through Cloudflare. Two-letter ISO country code (US, CA, GB). Preferred when present because Cloudflare’s edge is more accurate than IP databases for VPN/proxy detection.
  2. Other CDN-set country headersx-vercel-ip-country (Vercel edge), x-real-ip-country, etc. Picked up automatically when present, in case some customer traffic flows through a non-Cloudflare CDN that still tags country at the edge.
  3. request.ip → geoip-lite lookup — the Express-resolved client IP (which already accounts for trust-proxy chain) is passed through geoip-lite. Local lookup, no third-party API call. Returns the country code from MaxMind’s GeoLite2 database.
  4. x-forwarded-for first-hop → geoip-lite lookup — final escape if every previous step failed. Parses the leftmost IP in the x-forwarded-for chain (typically the original client IP before any proxy hops) and runs geoip-lite on it.
  5. Default: '' (Unknown) — if every step returns nothing — e.g., a private IP, a Tor exit not in the GeoLite2 DB, or a brand-new IP range MaxMind hasn’t catalogued yet — the country defaults to '' and the row writes as Unknown. Should be under 5% of typical DTC traffic.

How to verify country data is showing up

  1. Open the Geo card. Open the Dashboard’s analytics view. Scroll to the Geo card. You should see a country breakdown with non-zero rows for any visitors who hit the site after 2026-05-06.
  2. Set the range to last 7 days. The geo enrichment fix shipped 2026-05-06. Pre-fix rows persisted as Unknown — they aren’t backfilled (we don’t store raw IPs after the request, so we can’t replay them through the new chain). Setting the range to the last 7 days isolates the post-fix window where new traffic is fully resolved.
  3. Confirm the breakdown is sensible. If you sell to US-heavy DTC traffic you should see United States dominating, then a long tail of Canada, UK, Australia, Germany, etc. If 100% of new traffic still reads Unknown after the fix shipped, something else is wrong — see the next section.

What if it’s still empty

If new traffic is still resolving as Unknown after the fix date, walk these checks in order. Most workspaces hit one of the first three.

  1. Pixel isn’t firing. Check your /dashboard install verifier. If the pixel itself isn’t sending events, geo enrichment has nothing to resolve. Re-paste the snippet, hard-refresh, and make a test pageview from incognito to confirm.
  2. Your CDN strips client IPs. Cloudflare default settings forward the visitor IP via cf-connecting-ip / x-forwarded-for, so the pixel sees the real client IP. If you’re behind a custom proxy that rewrites all incoming IPs to its own, every row will resolve to the proxy’s home country (or Unknown). Confirm via your CDN dashboard that x-forwarded-for is preserved end-to-end.
  3. Most of your traffic is private / VPN / Tor. Some VPN exit IPs, datacenter IPs, and Tor relays aren’t in the MaxMind GeoLite2 database that geoip-lite ships. If your audience skews privacy-conscious (a privacy-tooling brand, a B2B SaaS sold to security buyers, etc.), expect a higher Unknown share than a typical DTC store. Anything under ~5% Unknown is healthy.
  4. Date range pre-dates 2026-05-06. Historical rows aren’t backfilled — they were written without country and stay that way. Move your date range to a window starting on or after 2026-05-06 to see the post-fix data only.
  5. Escalation. If none of the above explains it, open a chat with our team — share the date range, the Unknown share %, and a representative visitor_id. We’ll trace the request lifecycle from pixel hit to Tinybird row.

Privacy note

geoip-lite ships a binary database file (the MaxMind GeoLite2 Country DB) directly inside the npm package. The lookup runs locally inside the request handler — no outbound HTTP call, no third-party API, no IP-to-country service. The visitor IP is held in memory only for the duration of the single request, then discarded. Only the resolved country code persists to Tinybird. This is the same pattern Plausible and Fathom document in their privacy policies.

FAQ

Why was country data empty before 2026-05-06?

Admaxxer's pixel ingest reads country from the cf-ipcountry HTTP header — set automatically by Cloudflare's edge. That works perfectly when the customer's site is behind Cloudflare, which is most of the internet. But Admaxxer's own ingest endpoint (api.admaxxer.com via /api/event) is hosted on bare Hetzner, no Cloudflare proxy in front. For customer traffic that lands directly at our endpoint without passing through Cloudflare's edge, the cf-ipcountry header was never set and the pixel row got country=''. Result: rows wrote successfully, but the Geo card on /dashboard rendered Unknown for the country column. The fix is a server-side IP-to-country lookup using geoip-lite — the same library Plausible, Fathom, and most privacy-first analytics tools use.

How does the new resolution chain work?

On every /api/event request, the server now resolves country via this chain: (1) cf-ipcountry header (still preferred when present — Cloudflare's edge is more accurate than IP databases for VPN/proxy detection); (2) x-vercel-ip-country / x-real-ip-country / similar CDN-set headers; (3) request.ip (Express-resolved client IP) → geoip-lite lookup; (4) x-forwarded-for first-hop → geoip-lite lookup as a final escape. If every step returns nothing, country defaults to '' and the row writes Unknown. The chain is implemented in server/lib/geo/resolveCountry.ts.

Is my historical data backfilled?

No. Admaxxer never persists raw visitor IPs after the ingest request — we resolve country at ingest time and write only the country code. That's the right privacy model (no IP is ever at-rest in our DB) but it means we can't replay old rows through the new chain because we don't have the IP anymore. Rows that landed pre-2026-05-06 will continue to read Unknown for country forever. Going forward, every new row gets a country code as long as the IP is public.

What's the privacy posture? Are you sending IPs to a third party?

No. geoip-lite ships a binary database file (the MaxMind GeoLite2 Country DB) directly with the npm package. The lookup runs locally inside the Express request handler — no outbound HTTP call, no third-party API, no IP ever leaves Admaxxer's infrastructure for geo classification. The IP is only held in memory for the duration of the single request, then discarded. Only the resolved country code (e.g., 'US', 'CA', 'GB') persists to the Tinybird row. This is the same pattern Plausible documents in their privacy policy.

What share of traffic should resolve cleanly?

On typical DTC traffic, expect 95-98% resolved (real country code) and 2-5% Unknown. The Unknown bucket is some combination of: (a) privacy/VPN/Tor exit IPs not in MaxMind's DB, (b) datacenter IPs that aren't user-facing, (c) legacy bot traffic, (d) very-recently-allocated IP ranges that haven't propagated to MaxMind yet. If you're seeing >10% Unknown sustained, check the still-empty checklist below — there's likely a CDN-IP-stripping issue rather than a geoip-lite limitation.

Does this change the Geo card on /dashboard?

Yes — but only behaviorally, not visually. The card still renders the same chart and table; the country column just has fewer empty rows now. If you had a workspace where the Geo card felt useless because every row read Unknown, that workspace now shows real countries on every new pageview.

See also

Dashboard card reference · Missing attribution data · Attribution discrepancies · Missing orders · Sales mismatch · How data works