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 Payment Links, Stripe-hosted checkout, and any bank-redirect method (iDEAL, Bancontact, Przelewy24, local bank buttons). 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 show a Stripe-hosted confirmation page, and unless you configure a redirect back to your site, the customer never loads your thank-you page, so any tag placed there cannot fire. Even with a redirect configured, customers close the tab or finish 3DS in a banking app. Fire the event from the order-received page rendered server-side, and cover non-returners with a webhook-driven server-side event.
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 entirely.
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. If you want yours checked or fixed, email me at work@rytisbalys.com.
work@rytisbalys.com