You have successfully implemented server-side tracking using Node.js and the Google Analytics 4 (GA4) Measurement Protocol. Your events are arriving in the dashboard, and your revenue numbers match your backend database.
However, when you check the Traffic Acquisition report, those server-side conversion events fall into the black hole of (not set), Unassigned, or Direct.
Your "Purchase" events are disconnected from the "Session Start" events that occurred in the browser. Consequently, you cannot attribute revenue to the Google Ads campaign, SEO landing page, or email newsletter that drove the sale.
This guide provides the architectural root cause and a production-ready Node.js solution to stitch server-side events back to the original client-side session.
The Root Cause: Missing Session Context
To understand why attribution fails, you must understand how GA4 defines a "Session."
When a user visits your site, gtag.js (the client-side library) generates two critical identifiers:
- Client ID (
cid): Identifies the browser instance/user. - Session ID (
sid): Identifies the specific period of activity.
When you send a request via the Measurement Protocol (MP) from your Node.js backend, you likely include the client_id. This correctly links the event to the User.
However, if you omit the session_id, GA4 assumes this is a new session. Since the server request has no referrer header and no campaign parameters (UTM tags), GA4 classifies this new session as "Direct" or "Unassigned." The acquisition data remains trapped in the browser session, while the conversion data sits in a newly created, empty server session.
The Solution: You must capture the session_id on the client, pass it to your backend (e.g., via payment metadata), and include it explicitly in your Measurement Protocol payload.
Step 1: Extracting Identifiers on the Client
Do not attempt to parse _ga cookies manually. Cookie formats change, and the Session ID is often stored in a container-specific cookie (_ga_<container_id>) that is difficult to predict programmatically.
The reliable "Principal Engineer" approach is to use the gtag API to retrieve these values immediately before the user performs an action that triggers the backend process (e.g., clicking "Checkout").
Use the following utility function in your frontend application. This code is compatible with modern React, Vue, or vanilla JS.
/**
* Retrieves GA4 Client ID and Session ID safely.
* @param {string} measurementId - Your G-XXXXXXXXXX ID.
* @returns {Promise<{clientId: string, sessionId: string}>}
*/
export const getAnalyticsIds = (measurementId) => {
return new Promise((resolve) => {
// Ensure gtag is loaded
if (typeof window.gtag === 'undefined') {
console.warn('GA4: gtag not loaded');
resolve({ clientId: null, sessionId: null });
return;
}
// gtag('get') retrieves internal values
window.gtag('get', measurementId, 'client_id', (clientId) => {
window.gtag('get', measurementId, 'session_id', (sessionId) => {
resolve({
clientId: clientId || null,
sessionId: sessionId || null,
});
});
});
});
};
Passing Data to the Backend
Once you have these IDs, you must transmit them alongside your transaction data.
- Stripe/Payment Providers: Add them to the
metadataobject of your PaymentIntent. - Internal API: Send them in the JSON body of your checkout endpoint.
Step 2: The Node.js Implementation
Below is a robust service module for Node.js (v18+ using native fetch). This implementation handles the specific payload structure required to stitch the session.
Prerequisites
- Measurement ID: Found in GA4 Admin > Data Streams.
- API Secret: Generated in GA4 Admin > Data Streams > Measurement Protocol API secrets.
The Service Code
// analyticsService.js
const GA4_ENDPOINT = 'https://www.google-analytics.com/mp/collect';
/**
* Sends a server-side event to GA4 with session stitching.
*
* @param {Object} params
* @param {string} params.measurementId - 'G-XXXXXXXXXX'
* @param {string} params.apiSecret - The API Secret from GA4 Admin
* @param {string} params.clientId - From client-side (required)
* @param {string} params.sessionId - From client-side (CRITICAL for attribution)
* @param {string} params.eventName - e.g., 'purchase'
* @param {Object} params.eventParams - Additional event data (currency, value, items)
*/
export async function sendGA4Event({
measurementId,
apiSecret,
clientId,
sessionId,
eventName,
eventParams = {},
}) {
if (!measurementId || !apiSecret || !clientId) {
console.error('GA4: Missing required credentials or client_id');
return;
}
// 1. Construct the query parameters
const query = new URLSearchParams({
measurement_id: measurementId,
api_secret: apiSecret,
});
// 2. Build the payload
// note: 'session_id' must be passed strictly as an event parameter.
const payload = {
client_id: clientId,
events: [
{
name: eventName,
params: {
...eventParams,
// CRITICAL: This stitches the server event to the browser session
session_id: sessionId,
// Optional: engagement_time_msec helps GA4 validate active user engagement
engagement_time_msec: '100',
},
},
],
};
try {
const response = await fetch(`${GA4_ENDPOINT}?${query}`, {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
},
});
// GA4 MP typically returns 204 No Content even on errors,
// so network-level success is the primary check here.
if (!response.ok) {
throw new Error(`GA4 API Error: ${response.status} ${response.statusText}`);
}
// For debugging, you can use the /debug/mp/collect endpoint strictly in dev
// console.log(`GA4 Event '${eventName}' sent for session ${sessionId}`);
} catch (error) {
console.error('GA4 Measurement Protocol Failed:', error);
// In production, consider a retry mechanism or dead-letter queue here
}
}
Usage Example
Here is how you would call this function inside a webhook handler (e.g., after a successful payment).
// webhooks/stripe.js (or your controller)
import { sendGA4Event } from './analyticsService.js';
export async function handlePurchaseWebhook(req, res) {
const event = req.body;
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object;
// Retrieve metadata stored during checkout initialization
const { ga_client_id, ga_session_id } = paymentIntent.metadata;
await sendGA4Event({
measurementId: process.env.GA4_MEASUREMENT_ID,
apiSecret: process.env.GA4_API_SECRET,
clientId: ga_client_id,
sessionId: ga_session_id, // Pass this strictly
eventName: 'purchase',
eventParams: {
currency: paymentIntent.currency.toUpperCase(),
value: paymentIntent.amount / 100,
transaction_id: paymentIntent.id,
// Add specific items array here if available in metadata/DB
},
});
}
res.json({ received: true });
}
Deep Dive: Why This Works
The magic lies in the params object of the payload.
params: {
session_id: sessionId
}
When GA4 processes an incoming event, it looks up the client_id. If it finds a matching user, it then looks for an active session.
- Without
session_id: GA4 sees a timestamp gap or a lack of session context. It starts a new session. Since the request originated from your server (not a browser with history), this new session has no traffic source data. Result: (not set). - With
session_id: GA4 matches the incoming server event to the existing session currently active (or recently active) on the user's device. It inherits the source, medium, and campaign data from that session. Result: Organic Search / Google Ads Attribution.
Common Pitfalls and Edge Cases
1. The "Engagement Time" Trap
GA4 is aggressive about filtering out bot traffic or "bounced" sessions. Server-side events have no inherent "time on page."
- Fix: Always include
engagement_time_msec: '100'(or higher) in your event params. This signals to GA4 that this is a valid interaction, preventing the event from being discarded in certain reports.
2. Timestamp Issues
If your webhook is delayed (e.g., occurs 30 minutes after the user closes the browser), the session might have naturally expired (default timeout is 30 minutes).
- Behavior: Even with the correct
session_id, if the session has expired, GA4 may technically start a new session but attempt to attribute it based on theclient_idhistory. However, for best accuracy, ensure webhooks process as close to real-time as possible. - Override: You can send
timestamp_microsin the top-level payload if you need to backdate an event to exactly when the user clicked the button, rather than when the webhook fired.
3. User Consent (GDPR/CCPA)
Measurement Protocol requests bypass the browser's consent banner.
- Compliance: You must check the user's consent state (
analytics_storage) on the client before extracting IDs and passing them to the backend. If the user denied tracking, do not send theclient_idto your server or forward it to GA4.
4. Debugging
The standard collect endpoint returns 204 No Content even if your JSON is malformed.
- Fix: During development, change the URL to
https://www.google-analytics.com/debug/mp/collect. This endpoint returns a verbose JSON response detailing any validation errors.
Conclusion
The "(not set)" attribution error in GA4 is almost always a data continuity failure, not a platform bug. By treating the session_id as a first-class citizen in your backend logic—capturing it on the frontend and explicitly explicitly passing it in the Measurement Protocol params—you ensure your backend revenue data aligns perfectly with your frontend marketing data.