Iurii RoguliaIurii Rogulia
AboutAI EngineeringServicesProjectsAI SkillsStackTestimonialsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a numberThink of a birthday
Hourly ratesChecklistsPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

  1. Home
  2. /
  3. Blog
  4. /
  5. How GA4 Consent Mode Hides Sales and How to Count Them

Iurii Measures: How GA4 Consent Mode Hides Sales and How to Count Them

GA4 cannot tell you how many orders it never saw. Stripe can. A read-only script that reconstructs GA4 coverage from PaymentIntent metadata, and what it can and cannot tell you.

November 13, 2026· 8 min read

GA4 consent mode measurement gap: count the paid orders GA4 never saw by replaying its send gate against Stripe PaymentIntent metadata.

Stack

Next.jsTypeScriptNode.js

Services

StripeGoogle Analytics

Topics

AnalyticsConsent ModeE-commerceAttribution
How GA4 Consent Mode Hides Sales and How to Count Them

Short answer: you cannot measure the GA4 consent gap from inside GA4, but you can measure it from Stripe. Every succeeded checkout PaymentIntent is a real paid order. If you write the analytics identifiers onto the PaymentIntent metadata at checkout, you can replay your own ‘was this sent to GA4?’ rule over those orders and count how many fell out. No GA4 Data API, no read credentials for Google.

I did this for pi-pi.ee, a Next.js and Stripe store whose analytics I built. The tool is a one-off, read-only script. This article walks through the method, the code and, mostly, the ways the output can mislead you. The article contains no results from the script; what the numbers look like depends entirely on your traffic and your banner.

My earlier post on server-side tracking for GA4 and Meta describes the Pikkuna platform, a different system. This one is about a setup where server-side sending exists but is deliberately limited.

Why the gap is invisible inside GA4

Google Consent Mode has two implementations. Per Google’s Analytics Help page on consent mode, in the advanced one Google tags load before the consent dialog and send cookieless pings when consent is declined, and behavioural modelling can fill gaps. In the basic one tags are blocked until consent is granted, so nothing is collected before that.

This store is set up like the basic one. The inline default sets analytics_storage and the ad signals to denied and loads nothing from Google; gtag.js is injected only after the visitor grants a category. For a buyer who grants nothing there is no _ga cookie, no server-side purchase either (more on that below), and no cookieless ping. The order exists in Stripe and nowhere in GA4, and GA4 has no row that says ‘one order missing here’.

One edge case: the banner callback loads gtag.js when either analytics or marketing is granted. A visitor who grants marketing but declines analytics therefore loads gtag with analytics_storage still denied, which can behave like the advanced setup. The script counts that buyer as unmeasured, because the ids are empty, even though Google may have received pings from them.

There is a second layer. In this store the GA4 purchase event is not sent from the browser at all: it goes out only from the Stripe webhook, through the GA4 Measurement Protocol. So the sender’s rule is the only route a purchase has into GA4, and it returns early when there is no GA identity:

// sendMpEvent() in mp.ts, trimmed
if (clientId === "" || sessionId === "") {
  log.info({ event: event.name }, "Skipping MP event: no analytics consent");
  return;
}

Skipping the server-side send for those buyers is a deliberate decision in this codebase, not an oversight. It means the server-side send covers only buyers who already have a GA identity, and buyers without one stay invisible to GA4 by design. That is why you measure them against something else. For the consent background itself, see GDPR analytics without a cookie banner; I will not repeat it, and nothing here is a legal conclusion either way.

The method: replay the gate

The two identifiers are read in the browser at checkout and only when analytics consent is stored:

  • extractClientId() returns "" unless analytics consent is stored and a parseable _ga cookie exists.
  • extractSessionId() does the same for the per-property session cookie.

Both values go to the server with the checkout request, and the order-preparation code stamps them onto the PaymentIntent metadata next to the attribution fields:

// prepare-order.ts, metadata (trimmed)
gaClientId: gaClientId ?? "",
gaSessionId: gaSessionId ?? "",
attrSource: attribution ? attribution.source : "",

So the same condition that decides whether the server sends to GA4 is persisted on the payment. That gives a clean equivalence:

measured in GA4  <=>  gaClientId !== "" && gaSessionId !== ""
unmeasured       <=>  otherwise

You do not need to ask Google anything. You re-evaluate the gate against the ground truth.

The script

It is read-only: it lists PaymentIntents and never writes. It prefers a live Stripe key and falls back to STRIPE_SECRET_KEY. The window is --days=N (default 30) or --month=YYYY-MM.

The gate, copied over from the sender:

function isMeasured(pi: Stripe.PaymentIntent): boolean {
  return (pi.metadata.gaClientId ?? "") !== "" && (pi.metadata.gaSessionId ?? "") !== "";
}

And the loop:

for await (const pi of stripe.paymentIntents.list({
  created: { gte: win.gte, lt: win.lt },
  limit: 100,
})) {
  if (pi.status !== "succeeded") continue;
  // A real order always carries an orderNumber (stamped at PaymentIntent creation).
  if (!pi.metadata.orderNumber) continue;
  if (pi.metadata.customerEmail && SKIP_EMAILS.has(pi.metadata.customerEmail)) continue;
 
  const measured = isMeasured(pi);
  const gross = pi.amount_received;
  const source = pi.metadata.attrSource || "direct";
  // ...add to the total bucket and the per-source bucket
}

Three filters decide what counts as an order: status succeeded, a non-empty orderNumber in the metadata (so stray PaymentIntents from other flows are excluded), and a small set of internal test emails whose test checkouts would otherwise skew the ratio. for await over list() lets the Stripe SDK paginate for you.

Reading the output

The script prints to stderr, in three blocks:

BlockWhat it reports
TotalsPaid orders, how many have both ids, how many do not, gross amount of the unmeasured ones
By traffic sourcePer attrSource bucket: paid, measured, unmeasured, share unmeasured
Closing lineThe paid-to-measured ratio, as a multiplier for the purchases GA4 can have received

It also handles the edge cases: no paid orders in the window prints a message, and zero measured orders prints that none of the paid orders carry a GA identity for the period instead of dividing by zero.

Limitations: how the number can mislead

I would rather you distrust the output in the right places than trust it in the wrong ones.

‘Measured’ is an upper bound. Non-empty ids only mean the Measurement Protocol send was attempted. The sender swallows fetch errors with a warning, never checks the response, and returns early if the API secret is missing. An order can be ‘measured’ here and absent in GA4.

Ad blockers count as unmeasured. A consenting buyer whose blocker stops gtag also has empty ids. The script cannot tell ‘declined consent’ from ‘blocked gtag’; the bucket is ‘no GA identity at checkout’.

Amounts are gross. The script sums amount_received. Refunds are not subtracted.

The window uses created. The filter is on PaymentIntent creation time, not on when it succeeded. An intent created just before a boundary and paid just after lands in the earlier window.

The per-source table is a consent artefact. Attribution is stored and read only with marketing consent: readAttribution() returns null otherwise, and the pp_attr cookie is in the marketing cookie list. So attrSource is empty without marketing consent, and the script buckets every empty value as ‘direct’. The ‘direct’ row therefore mixes truly direct buyers with buyers who gave no marketing consent. A buyer with analytics consent but no marketing consent is measured, yet shows up as direct. Read the by-source table as a picture of consent, not of channels.

The multiplier is crude. The closing line scales the measured purchases (the ones GA4 can have received) by paid divided by measured. That assumes unmeasured buyers behave like measured ones, and there is no reason to believe that: people who decline tracking may differ in what they buy and how they arrived. Use it for orders of magnitude, not for reporting.

What to do with the number

  • Treat Stripe as the revenue and order count. Use GA4 for behaviour of the sample it can see. The script tells you how big that sample is relative to paid orders.
  • Re-run it after you change the banner or the consent defaults. The ratio is a property of your consent setup and traffic mix, so a change in either is a new measurement.
  • Do not backfill GA4 from the unmeasured set. In this codebase that is exactly what the skip prevents. Whether to change that is a policy question for you and your advisers; the number does not settle it.
  • Reconcile any second counter against Stripe too. In this codebase the purchase event is also sent server-side to a separate Umami instance, without the gate the Measurement Protocol sender has. I have not reconciled those counts against Stripe, so I do not treat them as measured. The privacy-first article above covers Umami for site traffic.

Adapting it to another stack

The pattern is portable; the field names are not:

  1. At checkout, write every identifier your analytics gate depends on onto the payment object (Stripe metadata, or the equivalent on your processor). Write empty strings, not omissions, so ‘absent’ is explicit.
  2. Keep a single shared definition of ‘would this be sent?’. If the sender and the report disagree, the report is fiction. Ideally import the same function.
  3. Mark real orders with something you control (here, an order number) so you can exclude test and unrelated payments.
  4. Record only what you need. Stripe metadata is limited, and none of this requires personal data beyond what the checkout already holds.
  5. If your payment events arrive by webhook, the same metadata is available there. The Stripe webhook article covers making that pipeline reliable.

The store this came from is described in the pi-pi.ee case study.

Related service

E-commerce

Not sure how many orders your analytics never sees? I keep Stripe the source of truth and measure tracking gaps instead of guessing.

More about this service →

Further reading:

  • GA4 Server-Side Tracking + Meta CAPI in Next.js – server-side sending on a different system
  • GDPR Analytics Without a Cookie Banner: 4 Tools Compared – the consent background
  • Stripe Webhooks: Idempotency, Retries, and Queue Setup – getting payment events reliably
  • pi-pi.ee – B2B E-commerce for Waterless Urinal Systems – the project
Iurii RoguliaAvailable

E-commerce

Not sure how many orders your analytics never sees? I keep Stripe the source of truth and measure tracking gaps instead of guessing.

More about this service

Relevant client work

View all projects
pi-pi.ee – B2B E-commerce for Waterless Urinal Systems
pi-pi.ee – B2B E-commerce for Waterless Urinal Systems
January 17, 2026
pi-pi.ee – B2B E-commerce for Waterless Urinal Systems

i18n B2B e-commerce platform for waterless urinal products across 32 European countries with automated VAT handling, PDF invoicing, and CRM integration.

pikkuna.fi – E-commerce for Vinyl Curtains & PVC Products
pikkuna.fi – E-commerce for Vinyl Curtains & PVC Products
October 12, 2024
pikkuna.fi – E-commerce for Vinyl Curtains & PVC Products

E-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe → Zoho CRM → Airtable → Mailgun → PDF.

tsstboxes.com – Fitment-First Online Store for ATV and UTV Cargo Boxes
tsstboxes.com – Fitment-First Online Store for ATV and UTV Cargo Boxes
November 10, 2026
tsstboxes.com – Fitment-First Online Store for ATV and UTV Cargo Boxes

Online store for ATV and UTV cargo boxes, each made for one model. Pick your machine, see only what fits, and pay in USD with shipping included.

What clients say

“

I’d built most of our MVP with Cursor and it looked finished – it compiled, the tests were green, the demo worked. It just wouldn’t survive real users.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

We wanted to add an AI feature that turns messy user notes into structured records, but our first attempt returned unpredictable JSON that broke the app half the time.

Bram de Vries 🇳🇱

Product Lead

Stack

Next.jsTypeScript

Services

OpenAI

Topics

AILLMStructured Outputs
“

I had a validated idea and a deadline tied to an accelerator demo day, but no product.

Aino Virtanen 🇫🇮

Founder

Stack

Next.jsTypeScript

Databases

PostgreSQL

Topics

MVPProductScopeSaaS

Related articles

Subscription Billing: The Edge Cases Stripe Docs Skip
July 22, 2026· 17 min
Subscription Billing: The Edge Cases Stripe Docs Skip

Subscription billing edge cases Stripe glosses over: proration, dunning, the state machine, cancellation timing, refunds, VAT, and webhook ordering.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQL
E-commerce Order Automation: Stripe + Invoice + Shipping Workflow
February 22, 2026· 10 min
E-commerce Order Automation: Stripe + Invoice + Shipping Workflow

E-commerce order automation that cut 160+ hours/month of manual work to zero: Stripe webhook to VAT invoice and tracking in under 2 minutes.

Stack

Next.jsTypeScriptNode.js

Databases

Redis
GA4 Server-Side Tracking + Meta CAPI in Next.js
September 5, 2025· 15 min
GA4 Server-Side Tracking + Meta CAPI in Next.js

Server-side GA4 and Meta CAPI in Next.js to recover the 30–50% of conversions adblockers and iOS hide from you.

Stack

Next.jsTypeScript
Checkout Totals: Why the Server Recalculates Every Time, Not Once
October 21, 2026· 11 min
Checkout Totals: Why the Server Recalculates Every Time, Not Once

A cached VAT table and a courtesy FX conversion don’t make a checkout trustworthy – recalculating the total server-side does.

Stack

TypeScriptNext.js
Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026· 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis