Polski
Doktor Monika

How a webhook recovered 40% of purchases lost at the payment gateway

How a webhook recovered 40% of purchases lost at the payment gateway

Summary

  • Problem: A WooCommerce store was losing around 40% of its transactions in Google Analytics 4, despite a working server-side layer in its strongest, same-origin form. The payment gateway ran a long redirect back to the store, and customers paid and closed the tab without ever returning to the thank-you page. Without that return, the purchase event had nowhere to fire.

  • Solution: A second source for the purchase event, added to the server-side layer already in place. A webhook from WooCommerce sends the transaction straight from the store backend, whether or not the customer came back from the gateway. It sends late, and only when the browser has not sent the purchase itself, so duplicates never arise.

  • Result: Transaction coverage in GA4 rose from around 60% to almost 100%, with traffic source attribution intact and no duplicates.

  • Stack: Google Tag Manager (web and server), GA4, Stape.io, Stape Store, Cloudflare Workers, WooCommerce, Action Scheduler, Consent Mode v2

01 / 03

Challenge

The payment gateway that kept 40% of transactions out of GA4

Standard purchase tracking in an online store rests on a single assumption. The customer finishes paying, comes back to the thank-you page, and the browser fires the purchase event there before anyone closes the tab. When that assumption holds, the number of purchase events in GA4 roughly matches the number of paid orders in the store, and nobody has any reason to put one figure next to the other.

In this implementation, on a WooCommerce store, the assumption failed for four orders in ten. The payment gateway ran a long redirect back to the store, and by then the customer already had confirmation from the bank and no reason to wait for anything. They paid and closed the tab. The order sat in the store admin with no matching event in GA4, because that event is only created on the thank-you page, and this kind of customer never went back to it.

The store already had server-side tagging in place, and in its strongest form. Measurement requests travelled through a path on the store's own domain, and cookies with a long lifetime were set by the server. Ad blockers and browser restrictions were a solved problem here, and transaction coverage still sat at around 60%.

A server-side layer receives the events a browser sends, and only then passes them on. Its work therefore begins at the moment the browser sends something. With the tab closed, the purchase event is never created, the server layer has nothing to receive, and the gap in measurement stays exactly where it was. This is not the problem of one platform or one gateway either. It affects every store where payment happens off-site and the purchase event waits for the customer to come back. The rest of this article describes the architecture that recovers those purchases, a second source for the purchase event sent straight from the store server. I also explain why it sits alongside the browser rather than replacing it, and where implementations of this kind usually break.

How I measured transaction coverage in GA4

Missing purchases are, by definition, invisible inside GA4, so there is no way to count them there. You need a reference point outside the measurement itself, a source that records sales regardless of what the customer's browser did.

The WooCommerce admin is that source. It records every paid order, because the order is created on the store server and depends on no script in any browser. I compared the number of paid orders against the number of purchase events in GA4 for the same period, and the ratio between those two figures gave the real transaction coverage. It came out at around 60%, with the server-side layer fully operational. I repeated the same measurement after the implementation, and that second run is the basis for the numbers later in this article.

What incomplete conversion data does to an advertising budget

The consequences of this gap did not stop at the report. The store owner was splitting his advertising budget on the strength of those same numbers.

Purchase data understated by four transactions in ten was also reaching the advertising platforms, and the Google Ads and Meta algorithms were learning from it. The conversion signal only arrived for some customers, so optimisation was working on a set smaller than the store's actual sales. The revenue of the missing transactions disappeared from the reports along with them, so a campaign that was genuinely profitable could look barely break-even.

The owner had quietly stopped trusting his own reports, because the GA4 figures did not match the order count in the store admin, and the only reference point he had for budget decisions was a report he did not believe.

02 / 03

Solution

A purchase sent from the store backend

Most descriptions of server-side tagging stop at longer-lived cookies and resistance to ad blockers. Both benefits are real, and this store was already getting both, yet neither closed the gap, because both only start working once the browser has something to send, which is to say once the customer has returned to the thank-you page. What was needed here was a different property of the approach, one that gets discussed far less often. If analytics data is processed by your own server rather than by the browser, then a conversion can be sent to that server from any source, including from the store backend at the moment WooCommerce confirms a paid order. Entirely regardless of whether the customer came back from the gateway or closed the tab five seconds earlier.

I have written about the server layer itself in a separate article, what server-side tagging is and when it genuinely makes sense.

The recovery architecture, in which the server is the fallback source

The server container had been standing in this store since an earlier stage of the implementation. It was the place the WooCommerce webhook could send an order to, and the place where session data previously written by the browser could be held. The recovery mechanism was built on top of it, and its entire value rests on one rule. The browser has priority, and the server sends an event only when the purchase did not come from the browser.

The browser and the webhook are connected by Stape Store, the key-value store built into server-side GTM hosted on Stape and operated from the container through dedicated tag templates. In this implementation it holds session fingerprints, so from here on I call it the session store. It is what lets the server enrich an incoming webhook event with the session data the browser recorded earlier.

How the delayed webhook, the order flag and the session store work together

The whole flow plays out in three acts, and only together do they add up to a system that does not count the same transaction twice.

In the first act the browser writes a session fingerprint, while the customer is still in checkout. Before payment begins, at the early steps of the funnel (begin_checkout, add_payment_info), the browser writes into the session store everything needed later to assemble a correct transaction: client_id, session_id, the consent status from Consent Mode, the real IP address, device data and the UTM parameters from the entry URL.

The session identifier and the UTM parameters cover two different parts of attribution on that list. The identifier from the Google Analytics cookie is enough on its own to assign a purchase to the right campaign when the traffic came from a Google property, organic results for instance. Correct assignment for campaigns running anywhere else, Facebook among them, depends on the UTM parameters.

In the second act the browser leaves a flag behind, but only if the customer returned from the gateway to the thank-you page. The web container fires the purchase event down the traditional path, and additionally writes a flag { "status": "processed" } into the store against the order number. That is the signal to the server: somebody has already handled this order.

In the third act the webhook checks whether it is needed at all. It reaches the server from WooCommerce deliberately delayed by three minutes, dispatched by Action Scheduler. That delay gives the browser time to send the purchase first. The server queries the store and takes one of two decisions. If it finds the flag, it sends nothing onward, because the transaction is already counted. If the flag is absent, it pulls the previously saved client_id and the rest of the session data out of the store, attaches them to the webhook payload and sends a complete transaction to GA4.

The result is a purchase recorded even when the customer closed the tab at the gateway. It arrives in GA4 with the correct identifier and assigned to a real traffic source, so it is fit both for reporting and for feeding bidding algorithms.

Sending purchases through the webhook alone and skipping the browser entirely looks simpler, since the server is the more reliable source. And because the server attaches session data from the store to the webhook transaction, that transaction does have a traffic source and session stitching does work. The difference is that data sent directly by the browser is usually more accurate and less prone to error in assigning traffic than a session stitched together after the fact. The better practice is therefore to base measurement primarily on browser events and keep the webhook as a fallback source for the purchases the browser did not send. That arrangement carries a second benefit. A webhook failure, after a plugin update or a change in store configuration, does not strip measurement of every purchase, because the browser path runs independently of it.

cart_hash as the key linking the webhook to the browser session

The whole mechanism rests on one condition, which is finding the session fingerprint the browser wrote into the store earlier. The webhook request reaches the server straight from the store backend and carries nothing that would tie it to a customer session. What is needed is a key the server can look the right record up by, and it has to be created in the cart, survive checkout and come back in the order payload. The choice of that value decides whether recovered purchases reach GA4 with complete session data and a correctly assigned traffic source.

I first used the customer's email address in that role. The choice looks natural, because the same address appears in both of the places the mechanism draws on, the checkout form the customer fills in and the webhook payload. In practice, matches went wrong more often than they should have, and part of the cause lay in how the key was written and read rather than in the addresses customers typed. So I introduced standardisation, lowercasing the address and trimming whitespace, applied identically when writing from the browser and when reading on the server, so that both sides would land on exactly the same key. It helped in the short term, matches went wrong less often, but the problem did not disappear.

So I moved to cart_hash, the thirty-two-character cart identifier WooCommerce assigns to every cart and returns in the order payload. The best joining key is one the platform creates itself, because it does not depend on what a human types or how.

Consent Mode v2 on the server, or how to reconstruct a user's consent

An event sent from the backend has to carry the user's consent exactly as an event sent from the browser does, and the only difference between them is that the store server has no access to that consent whatsoever. The consent banner runs in the browser, records the customer's decision there, and never talks to the store backend, so at the moment the webhook fires the server does not know whether consent was given or refused. The answer lies in the same place as the rest of the design, in the session store.

Alongside identifiers and device data, the browser also writes the customer's consent state into the store. On the server container side sits a lookup table that translates that stored state into the gcs parameter the Google protocol requires: G111 for full consent, G100 for its absence. The server appends that value to the request as it sends.

When consent is refused, GA4 receives a cookieless ping with G100. That signal carries no user identifiers, so the purchase itself is reported, only without being tied to a specific person or traffic source. Without it, that customer's purchase would leave no trace at all.

Consent is read here in the browser and used by the server some minutes later, on an event the browser never sees. The browser layer cannot reconstruct this on its own, because at the moment of sending there is nothing left for it to ask.

Four traps when implementing a webhook in server-side GTM

The design above looks simple as long as you only look at the flow of data, because a description like that hides the order of events in time and what happens when one of the writes does not complete. Before the system ran cleanly I had to work through four challenges, and every one of them is a classic failure in implementations of this kind.

The reversed data race. In the first version the webhook could reach the server before the browser had managed to write the session data. The server then asked the store for a document that did not exist yet, got a no-record answer, and the transaction was left without a session. The problem mainly hit the fastest-paying customers, mostly those using instant bank transfers. The fix had two steps. I moved the session write to the early steps of the funnel, and deliberately delayed the webhook by three minutes.

The `ecommerce` object sent as text. The GA4 tag was trying to pass the entire nested object as a single string, which ended in [object Object] in the payload. I excluded the raw object from the request and mapped the transaction fields by hand. In business terms this was a more serious problem than it looks, because the advertising platforms were rejecting the data on cart value and contents. They knew somebody had bought, but not what or for how much, so value-based bidding and product-level optimisation had nothing to work with.

The transaction attributed to the server instead of the customer. The webhook is sent by the WooCommerce backend, not by the customer's browser, so without a correction GA4 would attribute every purchase to the store server, with a WordPress user agent and a data-centre IP address. All geography and the device breakdown would be fiction. That is why the server overwrites the IP address and device data with the customer values written into the session store earlier.

`client_id` alone is not enough, even when the event only goes to GA4. Finding the session fingerprint in the store assigns the purchase to the right user, but not always to the right campaign. The identifier from the Google Analytics cookie carries the source when traffic came from a Google property, whereas campaigns running elsewhere need the UTM parameters saved earlier from the entry URL. That is why the store holds not the identifier alone but everything required to assemble a correct session, and why the write happens at the early steps of the funnel rather than at payment. When one of those values is missing, nothing signals it, because the event reaches GA4 perfectly normally. The absence only shows up later in reports, in purchases with no campaign or no device assigned.

03 / 03

Results

Transaction coverage in GA4 rose from 60% to almost 100%

Before the numbers, two outcomes of this implementation need separating: counting the transactions, and the quality of their attribution.

The counting is close to certain. The webhook originates in the WooCommerce backend and reaches the server in server-to-server communication, so it depends neither on the customer's browser nor on what is installed in it. It always arrives, and the full purchase payload arrives with it: value, cart contents, order number. From the point of view of the question of how much we actually sold, the gap effectively disappears, because counting a transaction does not require a client_id.

What I do not promise as a mathematical hundred per cent is perfect attribution of every single transaction. If the browser did not manage to write a session fingerprint, the webhook will not find an identifier to attach to the purchase. Such a transaction is still counted, and its value still enters revenue, only no campaign is named as its source. These cases are occasional, and the vast majority of recovered purchases reach GA4 correctly attributed, so what is at stake here is data quality rather than lost sales.

With that caveat, the implementation delivered the following:

  • Transaction coverage rose from around 60% to almost 100%. The same measurement method as at the start, paid orders in the WooCommerce admin set against purchase events in GA4. The first measurements after the implementation showed the full set of orders, though I do not assume that will hold in every window, since a single edge case can always turn up. Transactions that used to disappear on the redirect from the gateway now reach GA4 through the webhook. The server layer was identical in both measurements, so the entire recovery came from the second event source.

  • No duplicates. The order in which the server waits for the browser and checks its flag means a transaction is counted exactly once, either from the browser or from the server, never from both. The server blocks its own send whenever it sees the flag the browser left behind.

  • Source attribution recovered. In the vast majority of cases, recovered transactions carry a valid client_id and session_id, the UTM parameters from the entry URL and the rest of the data needed to assemble a session, so GA4 assigns the revenue to a real campaign instead of to traffic with no source.

  • Consent reconstructed on the server. The server rebuilds the gcs parameter from the state the browser wrote during checkout and attaches it to every hit it sends. When consent is refused, GA4 receives a cookieless ping instead of nothing at all.

  • The advertising algorithms receive the full set of signals. The systems learn from every customer rather than from six in ten, so the budget works on complete data.

Service: Server-Side Tagging

Want results like these?

Book a free consultation and see how I can help your business.