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_gacookie 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 <=> otherwiseYou 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:
| Block | What it reports |
|---|---|
| Totals | Paid orders, how many have both ids, how many do not, gross amount of the unmeasured ones |
| By traffic source | Per attrSource bucket: paid, measured, unmeasured, share unmeasured |
| Closing line | The 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
purchaseevent 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:
- 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.
- 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.
- Mark real orders with something you control (here, an order number) so you can exclude test and unrelated payments.
- Record only what you need. Stripe metadata is limited, and none of this requires personal data beyond what the checkout already holds.
- 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.
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








