David Lewin David Lewin
SEO, Traffic & Automation

Privacy-first WooCommerce analytics: server-side purchase tracking with Fathom vs GA4

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

GA4 sets cookies, so in the EU and UK it needs consent before it runs, and its scripts are on the main ad-blocker lists. Fathom Analytics sets no cookies (Fathom says no cookie banner is needed for it) and its script is about 2 KB, against about 150 KB for gtag.js. But a privacy-first tool isn't block-proof: when we ran the EasyPrivacy and uBlock privacy lists through Brave's filter engine, Fathom's script and event beacon were blocked just like GA4's. Fathom also has no API for sending events from a server, so the purchase event still comes from the browser. The reliable setup is therefore two-sided: a small mu-plugin sends one Fathom Purchase event per paid order from the thank-you page, and a WooCommerce order webhook feeds a server-side ledger that no ad blocker can touch. A reconcile command then compares the two through Fathom's API and tells you what share of orders and revenue the browser missed. Fathom is our pick for cookieless traffic and conversion analytics; GA4's Measurement Protocol does take server-side events, but it needs the visitor's GA client ID from a cookie, so consent still applies.

Store owners usually want two things from analytics: no cookie banner in front of every visitor, and purchase numbers that match the bank account. GA4 makes the first hard, because it uses cookies and needs consent in Europe. And no browser-based tool fully delivers the second, because ad blockers and closed tabs drop events. This guide compares GA4 with Fathom Analytics, a cookieless tool, and builds the piece both are missing: a server-side record of every paid order, fed by WooCommerce webhooks, that shows how much your analytics undercounts.

How this guide was made

We read Fathom's API reference and OpenAPI schema and its tracking script (October 2026). We ran the EasyPrivacy list and uBlock Origin's privacy list through Brave's filter engine (the adblock Python package, 0.6.0) against the requests both tools make. The mu-plugin and the Node.js ledger were tested on a real WordPress 7.1.3 and WooCommerce 11.2.1 site, with real webhook deliveries from WooCommerce and real thank-you page views. Fathom's API was a local mock, so we haven't measured a live Fathom account's capture rate. All 25 checks pass. This is technical guidance, not legal advice: ask your own counsel about consent rules where you sell.

1. The short answer

GA4 (gtag.js)Fathom AnalyticsServer-side ledger (below)
Cookies or device identifiersYes (_ga cookies)NoneNone (reads your own order data)
Consent bannerNeeded in the EU/UK before it loads; Consent Mode can send cookieless pingsFathom says no banner is neededNot an analytics tracker
Script size (compressed, measured)About 150 KBAbout 2 KBNothing in the browser
Blocked by EasyPrivacy / uBlockYesYesCan't be: server to server
Server-side event APIMeasurement Protocol (needs the cookie's client ID)NoneIs the server side
Traffic sources, pages, funnelsYes, plus ads integrationsYes, simple dashboardOnly WooCommerce's own order attribution

2. What ad blockers actually block

"Privacy-first" describes what a tool collects, not whether blockers let it through. We checked the requests both tools make against EasyPrivacy and uBlock Origin's privacy list, the lists most blockers ship with:

RequestResult
Fathom script, cdn.usefathom.com/script.jsBlocked
Fathom event beacon, cdn.usefathom.com/?name=Purchase…Blocked
GA4 googletagmanager.com/gtag/jsBlocked
GA4 google-analytics.com/g/collectBlocked
A proxy subdomain named fathom.yourshop.comBlocked (EasyPrivacy has a generic ://fathom. rule)
Your thank-you page and the Store APIAllowed

Two more facts close the usual workarounds. Fathom stopped offering custom domains in March 2023, saying uBlock and others blocked them anyway. And proxying the beacon through your own server doesn't help either: the request then reaches Fathom from a data-centre IP, and the Nuxt Scripts documentation notes that Fathom's bot detection flags those. Fathom's script does send beacons to whatever host it's loaded from, so the mechanism exists. But the bot filtering defeats it, and we don't recommend it.

So whichever tool you pick, some purchases won't reach the dashboard. The useful question is how many, and only a server-side count can answer it.

One paid order, two paths
One paid order, two pathsBrowser path / Server path → Ad blocker or closed tab / Your ledger service → Fathom dashboard / Reconcile commandBrowser path: thank-you page loads and the script sends a Purchase eventBrowser paththank-you page loads and thescript sends a Purchase eventServer path: WooCommerce fires the Order updated webhook when payment landsServer pathWooCommerce fires the Orderupdated webhook when paymentlandsAd blocker or closed tab: EasyPrivacy blocks cdn.usefathom.com and google-analytics.com, so the event is lostAd blocker or closed tabEasyPrivacy blockscdn.usefathom.com andgoogle-analytics.com, so theevent is lostYour ledger service: checks the HMAC signature and writes the order to disk, with no browser involvedYour ledger servicechecks the HMAC signature andwrites the order to disk,with no browser involvedFathom dashboard: conversions and revenue from visitors who weren't blockedFathom dashboardconversions and revenue fromvisitors who weren't blockedReconcile command: ledger vs Fathom's API gives the share the browser missedReconcile commandledger vs Fathom's API givesthe share the browser missed
The browser path gives you sources and pages but loses blocked visitors. The server path counts every paid order but knows little about the visit. Running both and comparing them shows the size of the gap.

3. GA4's costs for a store

  • Consent. GA4 stores a client ID in the _ga cookie. Under the EU and UK ePrivacy rules that needs consent, so visitors who decline or ignore the banner aren't measured (or, with Consent Mode, only as cookieless pings that Google models).
  • Data transfers. Several EU regulators ruled against Universal Analytics in 2022 over transfers to the US. Google now relies on the EU-US Data Privacy Framework, so this is mostly settled, but it remains a question your legal team has to sign off.
  • Weight. gtag.js was about 150 KB compressed when we fetched it with a placeholder measurement ID (a real container varies with its configuration), against about 2 KB for Fathom's script.
  • Server-side events still need the cookie. The Measurement Protocol (POST /mp/collect with an api_secret) accepts purchases from your server, but each event needs a client_id that matches the one GA's tag created, which lives in the cookie. Events can be backdated by at most 72 hours. Google now points new server-to-server integrations to its Data Manager API. Server-side GA4 fixes blocked browsers, not consent.

GA4 is still the right tool if you buy Google Ads and need its conversion import and audiences. If you mainly need traffic, sources and sales, it's a heavy way to get them.

4. What Fathom gives you, and what it doesn't

  • Cookieless counting. Fathom counts visits without cookies or anything stored on the visitor's device, and says its plans need no cookie banner. With EU isolation, EU visitors' pageviews go to servers in the EU, where the IP address is removed before anything reaches Fathom's US infrastructure.
  • Events with a value. fathom.trackEvent('Purchase', {_value: 9980}) records a conversion worth $99.80; the value is in cents and you set the currency per event in the dashboard. Events count toward your plan's monthly pageviews.
  • A read API. GET /v1/aggregations returns conversions and value for an event by site_id and entity_name. It's rate-limited per account: the included tier allows 600 requests an hour and 5 at a time.
  • No server-side event API. The API covers sites, events, milestones and reports. In the October 2026 OpenAPI schema, POST /sites/{id}/events returns 410 Gone, and there's no endpoint that records a pageview or a conversion. Any "server-side Fathom" setup either proxies the browser beacon (see the bot-detection problem above) or keeps its own count. We do the latter.
Fathom plan (monthly)Pageviews + events per month
$15100,000
$25200,000
$45500,000
$601,000,000
$1002,000,000

Larger tiers go up to 25 million. Every plan includes at least 50 sites and a 7-day free trial, and yearly billing gives two months free (pricing page, October 2026).

5. Step 1: one purchase event per paid order

Add Fathom's script to the site (its WordPress plugin or the embed code in your theme), then drop this into wp-content/mu-plugins/. It prints the event on the order-received page, only for paid orders, and only once: a refresh or a later visit from the order email doesn't count the sale twice.

<?php
/**
 * Plugin Name: Fathom purchase event
 * Description: Sends one Fathom "Purchase" event per paid order from the order-received page, value in cents.
 */

const FATHOM_PURCHASE_EVENT = 'Purchase';

add_action( 'woocommerce_thankyou', function ( $order_id ) {
	$order = wc_get_order( $order_id );
	// Paid orders only, once each: a refresh or a later visit to the thank-you page must not count the sale again.
	if ( ! $order || ! $order->is_paid() || $order->get_meta( '_fathom_purchase_sent' ) ) {
		return;
	}
	$order->update_meta_data( '_fathom_purchase_sent', time() );
	$order->save_meta_data();
	// Fathom wants the value in cents; purchase-ledger.mjs uses the same total x 100, so the two always compare.
	$cents = (int) round( $order->get_total() * 100 );
	printf(
		'<script>addEventListener("load",function(){window.fathom&&fathom.trackEvent(%s,{_value:%d})})</script>',
		wp_json_encode( FATHOM_PURCHASE_EVENT ),
		$cents
	);
} );

What we found while testing it:

  • Block themes fire woocommerce_thankyou from a block. In a block theme the order-confirmation page is a template, and the hook runs inside its "Additional information" block, only when the viewer may see the order. If you remove that block from the template, the event never prints. Classic themes fire it from checkout/thankyou.php.
  • Guest shoppers see the page for 10 minutes after ordering. After that WooCommerce asks them to verify their email first. The event normally fires right after checkout, so this rarely matters, but it's why the meta flag is set only when the script is actually printed.
  • Orders that aren't paid at checkout never fire the browser event (bank transfer, cash on delivery). The ledger records them when they're marked paid, so compare like with like or filter the ledger by payment_method.
  • "Coming soon" mode serves logged-out visitors a placeholder page, so test as a logged-out shopper only after launch.

6. Step 2: a server-side ledger from WooCommerce webhooks

In WooCommerce, go to Settings, Advanced, Webhooks and add one: topic "Order updated", API version WP REST API v3, a long random secret, and the URL of this service. It records each order once when it becomes processing or completed, and records a reversal if a paid order is later refunded or cancelled. It verifies WooCommerce's HMAC signature and writes the row before answering, so a 200 means the order is stored.

// Server-side purchase ledger for WooCommerce, reconciled against Fathom Analytics.
//   node purchase-ledger.mjs serve                      receive "Order updated" webhooks, append paid orders to the ledger
//   node purchase-ledger.mjs reconcile 2026-10-01 2026-10-19   ledger vs Fathom's "Purchase" event for those days
// Fathom has no API for sending events from a server, so the browser still sends the event (see fathom-purchase.php).
// The ledger is the count that ad blockers can't touch; reconcile shows how much the browser event misses.
// Node 20+, no dependencies.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { appendFileSync, existsSync, readFileSync } from 'node:fs';

const env = process.env;
const LEDGER = env.LEDGER ?? 'ledger.jsonl', TZ = env.REPORT_TZ ?? 'UTC';
const FATHOM_API = env.FATHOM_API ?? 'https://api.usefathom.com', EVENT = env.FATHOM_EVENT ?? 'Purchase';
const PAID = new Set(['processing', 'completed']), REVERSED = new Set(['refunded', 'cancelled']);

const readLedger = () => existsSync(LEDGER)
  ? readFileSync(LEDGER, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)) : [];
// The store's local day, so it lines up with Fathom, which reports in the site's timezone
const day = (iso) => new Intl.DateTimeFormat('en-CA', { timeZone: TZ }).format(new Date(iso));

function record(order, state) {
  const last = state.get(order.id);
  let kind = null;
  if (PAID.has(order.status) && last !== 'paid') kind = 'paid';
  else if (REVERSED.has(order.status) && last === 'paid') kind = 'reversed';
  if (!kind) return; // re-deliveries, pending/on-hold orders, completed after processing
  // WooCommerce's order attribution (8.5+) arrives as _wc_order_attribution_* entries in meta_data
  const a = Object.fromEntries((order.meta_data ?? []).map((m) => [m.key.replace('_wc_order_attribution_', ''), m.value]));
  const row = {
    kind, order_id: order.id, day: day((order.date_paid_gmt ?? order.date_modified_gmt) + 'Z'),
    cents: Math.round(Number(order.total) * 100), currency: order.currency, payment_method: order.payment_method,
    source_type: a.source_type || null, utm_source: a.utm_source || null, referrer: a.referrer || null,
  };
  appendFileSync(LEDGER, JSON.stringify(row) + '\n');
  state.set(order.id, kind);
}

function serve() {
  const state = new Map(readLedger().map((r) => [r.order_id, r.kind])); // later rows win: survives restarts
  createServer((req, res) => {
    const chunks = [];
    req.on('data', (c) => chunks.push(c)).on('end', () => {
      const raw = Buffer.concat(chunks), sig = Buffer.from(req.headers['x-wc-webhook-signature'] ?? '');
      const want = Buffer.from(createHmac('sha256', env.WEBHOOK_SECRET).update(raw).digest('base64'));
      if (req.method !== 'POST' || sig.length !== want.length || !timingSafeEqual(sig, want)) {
        res.writeHead(401).end();
        return;
      }
      if (raw.toString().startsWith('webhook_id=')) return res.writeHead(200).end(); // ping sent when the webhook is saved
      try {
        record(JSON.parse(raw), state); // a few ms of file append: write before answering, so a 200 means it's stored
        res.writeHead(200).end();
      } catch (e) {
        console.error(e.message);
        res.writeHead(500).end(); // WooCommerce retries; it disables the webhook after more than 5 failures in a row
      }
    });
  }).listen(Number(env.PORT ?? 8080), () => console.log(`listening on ${env.PORT ?? 8080}`));
}

async function fathomTotals(from, to) {
  const q = new URLSearchParams({
    entity: 'event', site_id: env.FATHOM_SITE_ID, entity_name: EVENT, aggregates: 'conversions,value',
    date_from: `${from} 00:00:00`, date_to: `${to} 23:59:59`,
  });
  for (let attempt = 0; ; attempt++) {
    const r = await fetch(`${FATHOM_API}/v1/aggregations?${q}`, {
      headers: { Authorization: `Bearer ${env.FATHOM_TOKEN}`, Accept: 'application/json' }, signal: AbortSignal.timeout(30000),
    });
    if (r.status === 429 && attempt < 3) { // rate or concurrency limit: Fathom says how long to wait
      await new Promise((ok) => setTimeout(ok, Number(r.headers.get('retry-after') ?? 5) * 1000));
      continue;
    }
    if (!r.ok) throw new Error(`Fathom ${r.status}: ${(await r.text()).slice(0, 200)}`);
    const [row = {}] = await r.json(); // numbers come back as strings
    return { orders: Number(row.conversions ?? 0), cents: Number(row.value ?? 0) };
  }
}

async function reconcile(from, to) {
  const orders = new Map(); // order_id -> cents, net of reversals, paid inside the window
  for (const r of readLedger()) {
    if (r.day < from || r.day > to) continue;
    if (r.kind === 'paid') orders.set(r.order_id, r.cents);
    else orders.delete(r.order_id);
  }
  const ledger = { orders: orders.size, cents: [...orders.values()].reduce((s, c) => s + c, 0) };
  const fathom = await fathomTotals(from, to);
  const pct = (a, b) => (b ? `${((100 * a) / b).toFixed(1)}%` : 'n/a');
  console.table({
    'Ledger (server)': { orders: ledger.orders, revenue: (ledger.cents / 100).toFixed(2) },
    'Fathom (browser)': { orders: fathom.orders, revenue: (fathom.cents / 100).toFixed(2) },
  });
  console.log(`Fathom captured ${pct(fathom.orders, ledger.orders)} of orders and ${pct(fathom.cents, ledger.cents)} of revenue`);
  return { ledger, fathom };
}

const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'serve') serve();
else if (cmd === 'reconcile' && args.length === 2) reconcile(...args).catch((e) => { console.error(e.message); process.exit(1); });
else console.log('usage: purchase-ledger.mjs serve | reconcile YYYY-MM-DD YYYY-MM-DD');
WEBHOOK_SECRET=... LEDGER=/var/lib/ledger/ledger.jsonl REPORT_TZ=Europe/Berlin node purchase-ledger.mjs serve

Run it under systemd or any process manager, behind your HTTPS reverse proxy. What our tests with real deliveries showed:

  • WooCommerce only delivers to ports 80, 443 and 8080. It sends webhooks with wp_safe_remote_request, and WordPress rejects private IPs and any port that isn't in its http_allowed_safe_ports list. A delivery URL like https://hooks.yourshop.com/ on 443 is the simplest choice.
  • Order attribution is already in the payload. WooCommerce 8.5+ stores the visit's source in _wc_order_attribution_* order meta, and the REST API includes those keys in meta_data, so the ledger can report revenue by source with no extra code. Be aware that this attribution uses short-lived first-party cookies (Sourcebuster). If your consent setup requires it, switch it off until consent with the wc_order_attribution_allow_tracking filter.
  • Re-deliveries are harmless. The service remembers each order's last state, including across restarts, so a repeated delivery adds nothing. WooCommerce disables a webhook after more than five failed deliveries in a row, so watch the service's health.

The ledger is a plain JSONL file you can load into any BI tool. If you already run a database, write the same row there instead.

7. Step 3: reconcile Fathom against the ledger

With a read-only Fathom API token, compare any date range:

FATHOM_TOKEN=... FATHOM_SITE_ID=ABCDEFG REPORT_TZ=Europe/Berlin \
  node purchase-ledger.mjs reconcile 2026-10-01 2026-10-19

It asks Fathom's aggregation endpoint for the Purchase event's conversions and value, nets refunds out of the ledger, and prints both side by side with the share Fathom captured. Set REPORT_TZ to the timezone of your Fathom site, so a day means the same thing on both sides. It retries on 429 using Fathom's Retry-After header.

Use the capture rate as a correction factor, not a curiosity. If Fathom sees 82% of orders, its conversion rates by source are directionally right but its totals are 18% low. Report revenue from the ledger, and use Fathom for traffic, sources and pages. Track the rate monthly: a sudden drop usually means a theme or checkout change broke the thank-you event.

8. Test results

AreaWhat we checkedResult
Filter listsFathom and GA4 scripts and beacons, a fathom. proxy subdomain, thank-you page, Store API7/7 as described in section 2
Ledger (real webhooks)Paid orders once each, on-hold skipped, refund as reversal, cents, attribution from meta_data6/6
Ledger serviceRe-delivery, bad signature (401), ping, restart without duplicates5/5
Thank-you page (real page views)Event with value in cents, not again on refresh, nothing for unpaid orders3/3
Reconcile (mock Fathom)Retry after 429, query parameters, bearer token, refund excluded4/4

9. Our opinion: trade-offs

  • For most WooCommerce stores, Fathom plus a server-side ledger beats GA4. You get a light, cookieless dashboard for traffic and sources, and order numbers you can trust from your own data.
  • Keep GA4 if you run Google Ads seriously. Conversion import, audiences and bidding need it, and so does consent management. Many stores run both: Fathom for everyone, GA4 only after consent.
  • Don't chase ad-blocker workarounds. Fathom gave up custom domains because blockers caught up, and proxied beacons get flagged as bots. Measure the gap instead.
  • The ledger is the part that lasts. It's your data, it outlives any analytics vendor, and it's the input a KPI dashboard needs (see the related guide below).

Related: automating e-commerce KPI dashboards: Databox vs Python and Google Sheets and scoring WooCommerce orders from webhooks.

10. Tools

Top pick

ToolRoleWhy it fits
Fathom AnalyticsCookieless website analyticsA 2 KB script with no cookies, events with revenue values, EU isolation and an aggregation API that the reconcile command uses. From $15 a month for 100,000 pageviews, with a 7-day free trial.

Alternatives: free tools and references

We don't earn anything from these links.

11. Pitfalls

  • Reporting revenue from any browser tool. Blocked visitors and closed tabs make it low; use the ledger.
  • Removing the "Additional information" block from a block theme's order-confirmation template, which silently stops the event.
  • A webhook URL on a non-standard port, which WooCommerce refuses to call.
  • Comparing different timezones. Set the ledger's REPORT_TZ to your Fathom site's timezone.
  • Calling cookieless "consent-free" for everything. Fathom sets no cookies, but WooCommerce's own order attribution does.
  • Proxying Fathom's beacon through your server, which gets real shoppers counted as bots.
Bottom line

Fathom gives a store cookieless, lightweight analytics without the consent and weight costs of GA4, but its script is on the same block lists, and it has no server-side event API. Send one purchase event per paid order from the thank-you page, record every paid order on your server from WooCommerce webhooks, and reconcile the two through Fathom's API. Then you know your real revenue and how much the dashboard misses. Keep GA4, behind consent, only if Google Ads needs it.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.