Your analytics says the site made 42,180 last month. Your order database says 39,650. Somebody asks which is right, and the honest answer is usually neither, for reasons that are individually boring and collectively expensive.
Here are the five causes, roughly in order of how often each turns out to be the one, with a way to tell them apart.
1. The confirmation page fires more than once
This is the most common cause by a distance, and it always inflates.
The purchase event usually lives on the order confirmation page. That page gets loaded again every time somebody refreshes it, hits back and then forward, opens it from their email, or has it restored by a browser that reopens tabs on start-up. Each of those fires the event again with the same value.
You can recognise it by the shape of the discrepancy: analytics is higher than the order database, the gap is a clean multiple on a handful of large orders, and the effect is worse on mobile, where back-navigation is more common.
The fix is a transaction id. Send the order number with the purchase event and let the analytics deduplicate on it. In GA4 the field is transaction_id; most tools use that name because the GA convention won. If your tag manager template has a field for it and you left it blank, this is your bug.
It is worth being blunt about what getting this wrong costs. One re-firing checkout page counting a single sale four times does not look like an error in a dashboard. It looks like a good month.
2. Two currencies added together
If you sell in more than one currency and your analytics reports a single revenue total, find out what it is doing before you trust it.
Some tools convert to a reporting currency at a rate fixed when the data arrived. Some convert at the rate on the day you look, which means a finished month changes value depending on when you open it. Some simply add the numbers up and put a symbol in front, producing a figure that is not money in any currency.
The tell is a total that sits between the correct converted figure and the naive sum, or a total that moves when you re-open a report for a month that closed long ago.
The safest arrangement is for the currency to be stamped on the event when it is recorded and for the tool never to convert. A mixed-currency report then either splits by currency or declines to give one total, both of which are more useful than a confident wrong number.
3. Refunds, cancellations and failed captures
Your order database is a live record. It knows that order 8814 was refunded on the 14th. Your analytics recorded the purchase on the 2nd and, unless you sent it a refund event, still believes in it.
Over a month with normal return rates this alone can account for several per cent, and it is the cause that makes analytics look high against a finance report rather than against an order table.
Related, and easier to miss: an order that was created but never captured. If the event fires on the confirmation page and the payment later fails, or the order sits unpaid and is cancelled by a timeout, analytics has a sale that your ledger never had.
4. Blocked, dropped and never-sent events
This one always deflates, and it is why analytics is usually lower than the order table when it is not being inflated by the causes above.
Content blockers stop the request. So do some corporate networks, some DNS-level filters, and some privacy-focused browsers by default. If your analytics needs consent, everybody who declined is missing too. And a purchase event that fires on page load races the visitor closing the tab, which they very often do the instant the order is confirmed.
The size of the blocked fraction varies enormously by audience -- a developer-tools site loses vastly more than a florist -- so any published percentage is worthless for your case. Measure your own: count the orders your database recorded against the purchase events analytics recorded, matched on order id, for a single clean week.
If you cannot match on order id, that is cause number 1 again.
5. The two systems are counting different moments
Finally, the boring one that explains the small residual left after you have fixed everything else.
Analytics timestamps the event in the visitor's session, in whatever timezone the report is set to. Your order database timestamps the row when the payment processor confirmed, in UTC, possibly minutes later and occasionally on the other side of midnight. A month boundary turns that into a real difference: always in the same direction, always small.
Attribution adds to it. Analytics assigns revenue to the session that converted -- or to some earlier session, depending on the attribution model -- while your order table assigns it to nothing at all. Comparing a last-click revenue total to a raw order total is comparing the answers to two different questions.
A reconciliation you can actually run
- Pick one week that has closed.
- Export orders: id, timestamp in UTC, currency, net value after refunds.
- Export purchase events with the same fields.
- Join on order id.
The four buckets that fall out are the diagnosis. Events with no order are double-fires or test traffic. Orders with no event are blocking or a broken tag. Matched pairs with different values are currency handling, or a tax-inclusive against tax-exclusive mismatch. Matched pairs on different days are timezone.
Do this once, write down the residual as a percentage, and you have something far more valuable than a matching total: a known and stable difference. Two systems that disagree by a consistent 4% are both usable. Two systems that agree by coincidence are not.
Where Skomi sits
Skomi reads the order id from order_id, and also from GA's transaction_id, so a site already tagged for GA4 needs no change. An event carrying an id it has already seen is not counted again.
Revenue is stamped with its currency at the moment it is recorded, and Skomi never converts between currencies -- a report covering two currencies shows two figures, because the single figure would be a fiction. There is more on the Analytics page, and installing the snippet covers sending the event.
Two definitions do most of the work here: revenue is attributed to the visit that earned it rather than the page it was paid on, and revenue currency is stamped at ingestion and never converted.