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.
| Scenario | Server event_name | Event_id | When to use |
|---|---|---|---|
| Initial purchase via client | purchase | same event_id | Client and server both send — dedupe in GA4 |
| Webhook renewal | purchase or subscription_renewal | new or persistent event_id | Renewals often occur without a client present — send server event with cid to attribute acquisition |
| Refund | refund | match original purchase id | Reverse 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.