# Why your WordPress sales and analytics never agree

Your WordPress orders and your analytics rarely show the same sales. Here's why browser tracking loses and double-counts them, and how to fix it.

Author: Hasan Misbah
Published: 2026-11-11
Reading time: 7 min read
Canonical: https://misbah.co/blog/why-your-wordpress-sales-and-analytics-never-agree

---


## The takeaway

Your analytics learns about a sale from the visitor's browser. Your shop learns about it from the database. The browser can miss a sale, count it twice, and never hear about the refund, so the two numbers drift. Count the sale where the order is written, then join it to the visit that led there.

---

Open your analytics and your orders page side by side, for the same week. The sales counts almost never agree. Most people shrug, assume one of them is "roughly right", and move on.

I build subscriptions, licensing and payment flows for a living, and in that world a mismatch is never "roughly right". The first of the [three questions I ask before I write billing code](/blog/three-questions-before-i-write-billing-code) is: what is the source of truth? Pick one system that's allowed to be right. Between your analytics and your orders, only one of them actually saw the sale happen. It isn't the analytics.

That gap is why I'm building Analyzine, a WordPress analytics plugin, with Sadiq Ahmad and Syed Aziz Mostoba. The three of us kept asking the same two questions about our own work. What actually turns a visitor into a customer? And which channel is really working for us? Our tools gave us numbers, but never a straight answer to either, which is the gap I wrote about in [I think we're asking website analytics the wrong questions](/blog/asking-website-analytics-the-wrong-questions). Analyzine is our attempt to answer both.

Sadiq wrote [why we wanted it](https://dearsadiq.com/why-im-building-analyzine/), and Aziz wrote [how it reads](https://azizmostoba.com/insights/designing-analyzine). This is how it counts.

## The browser is guessing

In most setups, a sale reaches your analytics like this: the buyer lands on the thank-you page, a script runs, and an event is sent. Every step can go wrong.

- **It never fires.** An ad blocker or privacy browser drops the script. The buyer paid, but your analytics never heard about it.
- **It never loads.** The buyer pays through an off-site gateway and closes the tab before being sent back. The order exists. The thank-you page was never seen.
- **It fires twice.** The buyer refreshes the thank-you page, or comes back to it from their email. Same order, two sales in your report.
- **It never takes it back.** A customer gets a refund, or an order is cancelled. Your shop's numbers change. The purchase your analytics recorded days ago doesn't.

None of these is a bug in your setup. It's what happens when the place that records the sale isn't the place where the sale happens. I learned the same lesson building [uptime checks at the edge](/blog/why-i-run-uptime-checks-at-the-edge): a probe in one place only tells you about its own path, not about your users'. A browser script only tells you what that one browser managed to send.

> The browser is a good witness to the journey. It's a bad witness to the money.

## Count the sale where it's written

The fix is to stop asking the browser. When WooCommerce, FluentCart, SureCart, Fluent Forms or LearnDash writes an order, lead or booking, that write is the sale. Analyzine records the conversion right there, on the server. A blocked script can't lose it, and a refresh can't double it.

Moving the count to the server fixes the first two failures outright. Reading from the order fixes the fourth: a refund or a cancellation is recorded on the order itself, so when the order changes, the sale in your reports changes with it. When I audit a payments codebase, the [refund path is the first place I read](/blog/the-refund-path-is-where-i-read-a-codebases-soul), because money going back out is where shortcuts show. Analytics deserves the same test. The third failure, counting twice, turned out subtler than I expected. More on that below.

In practice that means listening to each plugin's own hooks and actions: the events WooCommerce or Fluent Forms already fire when an order is placed or an entry is saved. Analyzine doesn't watch for a thank-you page or guess from a URL. It hears about the sale from the code that made it.

## Then join it to the visit

Counting on the server doesn't make the browser useless. Only the browser knows where someone came from, which pages they read, and what they did before buying. That's the journey.

The useful part is putting the two together. Analyzine keeps every visit in the site's own WordPress database, next to the orders. So "which campaign brought this sale" stops being an export and a spreadsheet. It becomes a query.

That's also what self-hosted means here: the data lives in your own database, on your own server, and nothing is sent to us or anyone else.

## Keeping the journey, not the person

There's a privacy side to the same idea. To tell you which channel brought a sale, Analyzine needs the visit and the order. It doesn't need to know who the visitor is. So it tracks the journey, not the person.

It can run without cookies, with a visitor ID that resets every day. Raw IP addresses are never stored, Global Privacy Control is respected, and tracking can wait for consent. Someone who returns next week counts as a new visitor, and that's fine: the numbers that matter are built on the order, not on recognising the person.

## What building it taught me

**The server counts twice too, if you let it.** I assumed that recording sales on the server would make duplicates disappear. It didn't. The same order reaches you more than once: a status change, a retry, a hook that fires again. I'd seen this before with [duplicate payment webhooks](/blog/six-payments-edge-cases-that-bite-at-2am), and the fix is the same: treat the order's own ID as an idempotency key and enforce it in the database, so the second arrival is recognised as the same sale.

**The hard part is the hosting, not the tracking.** Two things took more work than recording a visit. Page caching: a full-page cache can serve a page without WordPress running at all, so a naive tracker never hears about the visit. And database load: on shared hosting, every analytics write competes with the shop it's measuring. For caching, I kept the tracker out of its way entirely. The page can come straight from the cache and the tracker still reports the visit, so nobody has to turn caching off to get analytics. For the database, the answer was [deliberately boring](/blog/boring-code-is-a-feature-you-ship-to-your-future-self): a schema planned around the questions the reports ask, and indexes on the columns those queries use. My other plugins taught me that [your code runs on hardware you don't own](/blog/backward-compatible-migrations-on-an-install-base), on shared hosts with tight timeouts and tables with millions of rows. Analytics has to be planned for that from day one.

**What I left out mattered as much as what I built.** No data sent out: no vendor server and no third-party scripts in the product. And no attribution model. Each order shows the source it came from by one plain rule: the last visit before the sale gets the credit, every time. It isn't a model you tune or defend in a meeting. It's a rule anyone can check by opening the journey.

Each of those was something people would reasonably ask for. Leaving them out is what kept the numbers simple enough to check.

---

Analyzine is aiming for the first week of December. The [waitlist is open](https://analyzine.com/). If your analytics and your orders have never agreed, I'd like to hear which one you ended up trusting.
