How to stitch server-side events to recover missing ga4 revenue for subscription products

How to stitch server-side events to recover missing ga4 revenue for subscription products

I ran into this problem last year while auditing a mid-market SaaS client: GA4 was underreporting revenue for subscription products by a worrying margin. The short story was that client-side events (browser / app) were getting blocked by ad blockers and ITP, while server-side events were firing without a reliable stitch to tie them to the original session or user. The fix was to stitch server-side events back to the client-side footprint so GA4 could deduplicate and credit the right acquisition sources — and restore the missing revenue in reports.

Why stitching matters for subscription revenue

Subscriptions create two measurement frictions that make stitching essential:

  • Long lifespan: A conversion can happen days or weeks after acquisition (trial to paid), so you need persistent identifiers to connect the payment event to the original user/session.
  • Server-side flows: Renewals, webhooks and payment gateways often report revenue from the server (or a payment provider) rather than the client. If those server events aren't linked to the original client session, GA4 treats them as new, unattributed events, or discards duplicates.

Stitching fixes both. It allows server-side events (like payment.confirmation) to carry the same identity markers as client-side events (page_view, session_start), letting GA4 deduplicate and attribute revenue correctly.

Core concepts before you start

  • Client ID (cid) / User ID (uid): The persistent identifier sent by the client (cookie or app). This is the primary key to stitch to if you can persist it.
  • Event ID (eid): A unique identifier for each event; helpful for deduplication between client and server events.
  • Measurement Protocol (GA4 MP): The endpoint used to send server-side events to Google Analytics 4. Properly structured MP hits can include cid/uid and event_id.
  • Deduplication: GA4 deduplicates events when server and client events share the same event_name and event_id within a 1-hour window. You need to plan around that.

High-level stitching workflow I use

  • Capture identifiers on the client when the user interacts (cid, event_id, optionally a hashed user email or external user_id).
  • Persist those identifiers server-side (session DB, order record, subscription record).
  • When a server-side event occurs (payment, renewal, refund), include the original client identifiers and the same event_id where applicable.
  • Send the server event to GA4 via Measurement Protocol with the identifiers. If the client also sent an event, ensure event_id matches for deduplication, or use different event_names intentionally.
  • Validate events in GA4 DebugView and export to BigQuery for reconciliation.

Practical implementation steps

Here’s how I typically execute the stitching in an existing subscription stack that uses a front-end, back-end and a payment gateway (Stripe, Adyen, etc.).

1) Capture and persist a stable identifier on the client

On the website or app, capture the GA4 client id (called gclid in GA4 cookie or _ga cookie). In web environments you can read it from the _ga cookie and surface it on key form submissions (signup, checkout). On mobile, read the SDK's client ID. Also generate an event_id for the payment initiation event if you expect the server to also send an equivalent event later.

Example: at checkout, embed these fields in the order payload to the server: client_id (cid), event_id, and optionally a hashed email (sha256) as user_pseudo_id.

2) Persist identifiers on the server

Save cid and event_id in your order/subscription table. This is the single most important step — without persistence you can't retrospectively stitch renewals or refunds to the original client session.

3) Map events and decide deduplication strategy

You must choose whether the server will send the same event_name with the same event_id (so GA4 deduplicates) or send a separate event_name (e.g., subscription_payment_server) and use the same cid/uid for attribution but not deduplication. I generally prefer deduplication for the initial payment: send the same event_name (purchase) and the same event_id from the server when the payment completes.

ScenarioServer event_nameEvent_idWhen to use
Initial purchase via clientpurchasesame event_idClient and server both send — dedupe in GA4
Webhook renewalpurchase or subscription_renewalnew or persistent event_idRenewals often occur without a client present — send server event with cid to attribute acquisition
Refundrefundmatch original purchase idReverse revenue and link to original transaction

4) Send server events to GA4 using Measurement Protocol

Your server should call the GA4 Measurement Protocol endpoint and include:

  • api_secret and measurement_id in the URL (recommended to store secrets securely)
  • client_id (or user_id) in the payload
  • event_name and event_params including value, currency, transaction_id and event_id

Important: include event_id both client-side and server-side for any event where both may fire. GA4 will deduplicate if those align.

5) Respect consent and privacy

If a user has not consented to analytics tracking, you must not send their client identifiers to GA4. For subscriptions where server events are necessary for billing, send minimal telemetry and avoid personally identifiable information unless you have consent or a lawful basis. Hash emails if you need to use them for stitching and make sure your privacy policy reflects the practice.

6) Reconcile with BigQuery

I always export GA4 to BigQuery and create daily joins between your internal billing data and GA4 events. This gives you a clear view of what revenue is missing and whether the stitch is working.

Quick reconciliation query approach:

  • Join your orders table on transaction_id / order_id to GA4’s purchase events using event_params.transaction_id.
  • Also join on client_id and timestamps to catch cases where transaction_id wasn’t sent.
  • Flag unmatched revenue and inspect whether cid or event_id were missing in server hits.

Common pitfalls I’ve seen (and how to avoid them)

  • Missing or stale client_id: If you fail to persist the client_id at checkout, you can't attribute server events correctly. Fix: always write cid into the server order record during checkout.
  • Event_id mismatches: Generating event_id on client and server independently breaks deduplication. Fix: generate on client and pass that id to the server.
  • Payment gateway retries: Gateways may retry webhooks — ensure idempotency in your server logic and preserve the same transaction_id to avoid double-reporting.
  • Time windows for deduplication: GA4 dedupes within a limited timeframe. If the server event is delayed beyond that window, GA4 may not dedupe and you'll see duplicates. Fix: design for sending the event as soon as payment is confirmed; for delayed events, consider using distinct server event_names and handle attribution through cid.
  • Consent gating: If the client didn’t consent, but the server still sends events with client identifiers, you can violate privacy rules. Fix: have a consent API so the server knows whether to send analytics events for a given user.

How I validate success

I run a two-track validation:

  • Short-term: Use GA4 DebugView while performing test purchases. Confirm events arrive with client_id and event_id present, and that duplicates are deduplicated.
  • Medium-term: Reconcile daily revenue in BigQuery between billing and GA4. Look for closing of the revenue gap and correct acquisition attribution for subscription renewals.

One client I worked with reduced "missing revenue" from 23% to under 5% in three weeks by implementing persistent cid storage, server-side MP events with shared event_id, and BigQuery reconciliation. The remaining gap was mostly due to strict privacy settings on some browsers — an expected residual.

If you want, I can walk through a concrete Measurement Protocol payload example and a sample BigQuery join for your stack (Stripe/Node, Django, whatever you run). Tell me which payment provider and backend language you’re using and I’ll draft code snippets you can drop into your pipeline.


You should also check the following news:

Martech

How to build a privacy-first first-party attribution layer using server-side events and a low-cost data warehouse

29/08/2026

I want to walk you through a practical, privacy-first approach to building a first-party attribution layer. Over the last few years I've helped teams...

Read more...
How to build a privacy-first first-party attribution layer using server-side events and a low-cost data warehouse