The order is in WooCommerce. The money is in Stripe. GA4 shows nothing, or a purchase with value 0, or a number that matches the cart but not what the customer actually paid. The cause is almost always the same: your purchase tag depends on the customer's browser making the round trip back to your thank-you page with the right data in hand, and off-site payment breaks that trip.
This applies to Stripe-hosted checkout through the WooCommerce gateway and to any bank-redirect method (iDEAL, Bancontact, Przelewy24, local bank buttons). Standalone Stripe Payment Links behave differently, because they create no WooCommerce order at all - see the FAQ. I fix this setup for stores weekly. Here is what actually goes wrong and the pattern that survives.
A purchase tag that lives on the order-received page only fires if the customer's browser loads that page. With off-site payment, a meaningful share of paying customers never do:
Typical damage: 10-30% of purchases missing from GA4 and Meta, which quietly starves your ad optimization of the conversions it needs.
A common workaround: before the redirect, a script reads the checkout total from the DOM, stores it in localStorage, and reads it back on the return page. I remove this pattern from client sites regularly. It fails four ways:
1 234,56 €. One locale change, one theme update to the totals markup, and parseFloat returns NaN. GA4 drops or mangles the value silently.The deeper problem: the browser is the wrong source of truth for money. The backend already knows the real total.
WooCommerce's order-received URL contains the order ID and key. The server can load the full order and print a dataLayer push with backend numbers. No scraping, no storage, no guessing.
add_action( 'woocommerce_thankyou', function( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order || ! $order->is_paid() ) {
return; // do not fire for pending or failed redirects
}
if ( $order->get_meta( '_purchase_tracked' ) ) {
return; // refresh guard: fire once per order
}
$order->update_meta_data( '_purchase_tracked', 'yes' );
$order->save();
?>
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
event: 'purchase',
transaction_id: '<?php echo esc_js( $order->get_order_number() ); ?>',
value: <?php echo (float) $order->get_total(); ?>,
currency: '<?php echo esc_js( $order->get_currency() ); ?>'
});
</script>
<?php
} );Three details carry the weight here:
$order->get_total() is what was actually charged, including deposits, coupons, and gift cards. It cannot be NaN.is_paid() stops you from reporting revenue on payments that later fail.This fixes every case where the customer does return. For the customers who never come back at all, no on-page code can help. That class is solved server-side: the Stripe webhook confirms payment, and the server sends the purchase directly via GA4 Measurement Protocol and Meta Conversions API, deduplicated against the browser event by transaction_id and event_id. That is the setup I build for clients when the redirect losses are large.
Do not trust the plugin settings page. Run a real test order through the real off-site flow, 3DS included, and watch the wire:
purchase event appears in the dataLayer and that your GA4 event tag fired on it. The Tag Assistant connection sometimes drops across the Stripe redirect - if it does, verify via the network tab instead.collect. Find the POST to google-analytics.com containing en=purchase. Check epn.value against the order total and cu against the currency. If the request is absent, the tag never fired. If value is 0 or missing, your data source is broken.debug_mode set), open Admin, then DebugView. Confirm the purchase arrives with transaction_id, value, and currency populated.Even when the purchase fires, GA4 can credit it to the wrong channel. The customer leaves your site for stripe.com or their bank's 3DS page and comes back. GA4 sees stripe.com as the referrer, starts a new session, and your paid or organic attribution is overwritten by referral / stripe.com.
The fix is the unwanted referrals list. In GA4: Admin, then Data streams, select your web stream, open Configure tag settings, choose Show all, then List unwanted referrals. Add stripe.com. If your acquisition reports also show bank or 3DS authentication domains as referrers, add those too. GA4 will then ignore these referrers and keep the original session source intact.
Check the result in Reports under Traffic acquisition: referral traffic from stripe.com should drop to zero for new sessions, and your purchases start attributing to the campaigns that actually earned them.
Payment Links are the one case where the pattern in this article does not apply. A standalone Payment Link creates no WooCommerce order: Stripe hosts the page and the redirect goes to whatever URL you configured, not to order-received with an order ID and key. There is no order object to read, so a thank-you-page tag has nothing to fire on. Track these from Stripe's webhook alone, keyed on the Checkout Session, and send the purchase server-side. If you are using Stripe Checkout through the WooCommerce gateway instead, an order does exist and everything above applies.
The trip to Stripe or the bank's 3DS page and back looks like an inbound referral to GA4, so it breaks the session and reassigns the purchase to stripe.com. Add stripe.com to the unwanted referrals list in your web stream's tag settings. The original campaign source then survives the payment round trip.
Not with browser tags. The reliable route is server-side: Stripe's webhook confirms the payment on your backend, which sends the purchase to GA4 via Measurement Protocol and to Meta via Conversions API, deduplicated against any browser event by transaction_id and event_id. This recovers the closed-tab and cross-device cases, with one condition people skip: capture the GA4 client_id (and session_id, if you want session-scoped attribution) at checkout and store it on the order, then send it with the webhook event. Without it the purchase still lands in GA4, but unattributed.
Usually the tag reads the value from the browser: a scraped checkout total stored in localStorage that did not survive the redirect, or a formatted price string that parsed to NaN. Deposits and gift cards make the scraped cart total wrong even when it survives. Take the value from the WooCommerce order object on the backend instead - it is the amount actually charged.
I build and repair server-side tracking for WooCommerce and Shopify stores - redirect payments, webhooks, Measurement Protocol, Conversions API. Send me your two numbers below and I will tell you which of the causes above is yours, no charge. If the gap is under about 10%, you probably do not need me, and I will say so.
or email work@rytisbalys.com · every rated job 5.0 · verify on Upwork