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.
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.
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 Analytics | Server-side ledger (below) | |
|---|---|---|---|
| Cookies or device identifiers | Yes (_ga cookies) | None | None (reads your own order data) |
| Consent banner | Needed in the EU/UK before it loads; Consent Mode can send cookieless pings | Fathom says no banner is needed | Not an analytics tracker |
| Script size (compressed, measured) | About 150 KB | About 2 KB | Nothing in the browser |
| Blocked by EasyPrivacy / uBlock | Yes | Yes | Can't be: server to server |
| Server-side event API | Measurement Protocol (needs the cookie's client ID) | None | Is the server side |
| Traffic sources, pages, funnels | Yes, plus ads integrations | Yes, simple dashboard | Only 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:
| Request | Result |
|---|---|
Fathom script, cdn.usefathom.com/script.js | Blocked |
Fathom event beacon, cdn.usefathom.com/?name=Purchase… | Blocked |
GA4 googletagmanager.com/gtag/js | Blocked |
GA4 google-analytics.com/g/collect | Blocked |
A proxy subdomain named fathom.yourshop.com | Blocked (EasyPrivacy has a generic ://fathom. rule) |
| Your thank-you page and the Store API | Allowed |
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.
3. GA4's costs for a store
- Consent. GA4 stores a client ID in the
_gacookie. 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/collectwith anapi_secret) accepts purchases from your server, but each event needs aclient_idthat 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/aggregationsreturns conversions and value for an event bysite_idandentity_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}/eventsreturns410 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 |
|---|---|
| $15 | 100,000 |
| $25 | 200,000 |
| $45 | 500,000 |
| $60 | 1,000,000 |
| $100 | 2,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_thankyoufrom 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 fromcheckout/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 itshttp_allowed_safe_portslist. A delivery URL likehttps://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 inmeta_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 thewc_order_attribution_allow_trackingfilter. - 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
| Area | What we checked | Result |
|---|---|---|
| Filter lists | Fathom and GA4 scripts and beacons, a fathom. proxy subdomain, thank-you page, Store API | 7/7 as described in section 2 |
| Ledger (real webhooks) | Paid orders once each, on-hold skipped, refund as reversal, cents, attribution from meta_data | 6/6 |
| Ledger service | Re-delivery, bad signature (401), ping, restart without duplicates | 5/5 |
| Thank-you page (real page views) | Event with value in cents, not again on refresh, nothing for unpaid orders | 3/3 |
| Reconcile (mock Fathom) | Retry after 429, query parameters, bearer token, refund excluded | 4/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
| Tool | Role | Why it fits |
|---|---|---|
| Cookieless website analytics | A 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.
- GA4 Measurement Protocol: Google's server-side event API, if you stay with GA4.
- Plausible Community Edition: open-source cookieless analytics you can host yourself.
- EasyList and EasyPrivacy: the filter lists we tested against.
- WooCommerce webhooks: setup and delivery behaviour.
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_TZto 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.
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.