Custom Clients
The built-in GA4 client handles one data source: browser-originated GA4 Measurement Protocol requests. Custom clients let your sGTM server receive and process any HTTP request — CRM webhooks, mobile app events, order management notifications, server-to-server API calls — and transform them into the standard Event Model that your existing server-side tags already understand.
When to build a custom client
Section titled “When to build a custom client”Build a custom client when:
- You want to receive webhooks from Stripe, HubSpot, Shopify, or another service and forward conversion data to ad platforms
- Your mobile app sends custom events in a proprietary format
- You are building a server-to-server tracking pipeline that bypasses the browser entirely
- You want to receive events from a third-party analytics platform and redistribute them
- You need to process offline conversion data from your CRM or order management system
Do not build a custom client when the GA4 client handles your use case. Custom clients add development overhead and maintenance burden. If your data source can send standard GA4 Measurement Protocol requests, use the built-in client.
The sandboxed JavaScript environment
Section titled “The sandboxed JavaScript environment”sGTM client templates execute in a sandboxed JavaScript environment — not standard browser JavaScript. The available APIs are specifically designed for server-side template development:
Request APIs (reading incoming data):
getRequestPath()— path of the incoming request (e.g.,/webhooks/stripe)getRequestMethod()— GET, POST, PUT, etc.getRequestHeader(name)— value of a specific request headergetRequestBody()— raw request body as a stringgetRequestQueryParameter(name)— value of a query parametergetAllRequestQueryParameters()— object with all query parameters
Response APIs (sending back to the caller):
returnResponse()— send the HTTP response back to the callersetResponseStatus(code)— set HTTP status code (200, 204, 403, etc.)setResponseHeader(name, value)— set a response headersetResponseBody(body)— set the response body text
Processing APIs (building the Event Model and running the container):
claimRequest()— claim this request for this clientrunContainer(event, onComplete, onSuccess, onFailure)— run the sGTM container with the event model you builtsetEventData(event)— set the Event Model data
Utility APIs:
JSON.parse()— parse JSON strings (available in the sandbox)getTimestampMillis()— current timestamp in millisecondslogToConsole(message)— write to Cloud Logging
Basic custom client structure
Section titled “Basic custom client structure”Every client template follows this structure:
// 1. Decide whether to claim this requestconst requestPath = getRequestPath();if (requestPath !== '/webhooks/my-service') { // Not our request — do not claim, let other clients evaluate return;}
// 2. Claim the requestclaimRequest();
// 3. Parse the request bodyconst requestBody = getRequestBody();let payload;try { payload = JSON.parse(requestBody);} catch (e) { // Invalid JSON — return error response and stop setResponseStatus(400); setResponseBody('Invalid JSON payload'); returnResponse(); return;}
// 4. Build the Event Modelconst eventData = { event_name: payload.event_type, client_id: payload.user_id || payload.anonymous_id, // Map payload fields to Event Model properties order_id: payload.order.id, value: payload.order.total_price, currency: payload.order.currency,};
// 5. Run the container (fires triggers → tags)runContainer(eventData, () => { // onComplete callback — container finished executing setResponseStatus(200); returnResponse();});Practical example: Stripe webhook client
Section titled “Practical example: Stripe webhook client”Stripe sends webhook events when payments are processed. This client receives Stripe purchase events and makes them available to server-side tags for conversion forwarding:
// Stripe webhook client templateconst requestPath = getRequestPath();const requestMethod = getRequestMethod();
// Only claim POST requests to /webhooks/stripeif (requestPath !== '/webhooks/stripe' || requestMethod !== 'POST') { return;}
claimRequest();
// Validate Stripe signature (recommended in production)const stripeSignature = getRequestHeader('stripe-signature');if (!stripeSignature) { setResponseStatus(401); setResponseBody('Missing signature'); returnResponse(); return;}
// Parse the Stripe eventconst rawBody = getRequestBody();let stripeEvent;try { stripeEvent = JSON.parse(rawBody);} catch (e) { setResponseStatus(400); returnResponse(); return;}
// Only process payment_intent.succeeded events for conversion trackingif (stripeEvent.type !== 'payment_intent.succeeded') { // Acknowledge other event types without processing setResponseStatus(200); returnResponse(); return;}
const paymentIntent = stripeEvent.data.object;
// Build the Event Model for a purchase eventconst eventModel = { event_name: 'purchase', // Use your customer ID system to map to a client_id client_id: paymentIntent.metadata.ga_client_id || paymentIntent.customer, transaction_id: paymentIntent.id, value: paymentIntent.amount / 100, // Stripe uses cents currency: paymentIntent.currency.toUpperCase(), // Pass through to enable downstream enrichment stripe_customer_id: paymentIntent.customer, payment_method: paymentIntent.payment_method_types[0],};
runContainer(eventModel, () => { setResponseStatus(200); setResponseBody(JSON.stringify({received: true})); returnResponse();});Practical example: simple webhook receiver
Section titled “Practical example: simple webhook receiver”A minimal custom client for receiving any JSON POST request:
const requestPath = getRequestPath();
// Claim any POST to /eventsif (requestPath !== '/events') { return;}
claimRequest();
const rawBody = getRequestBody();let payload = {};
if (rawBody) { try { payload = JSON.parse(rawBody); } catch (e) { setResponseStatus(400); returnResponse(); return; }}
// Map payload to Event Model// Assumes payload has standard fields; adapt to your formatrunContainer({ event_name: payload.event || 'custom_event', client_id: payload.client_id || payload.user_id || 'anonymous', // Spread all payload properties into the Event Model // Access them as Event Data variables in tags ...payload,}, () => { setResponseStatus(200); returnResponse();});Setting response headers and cookies
Section titled “Setting response headers and cookies”Custom clients can set response cookies — useful for server-side identity assignment when processing non-browser requests that eventually result in a browser response:
// Set a tracking cookie in the responsesetResponseHeader( 'Set-Cookie', '_my_id=abc123; Max-Age=63072000; Path=/; Domain=.yoursite.com; Secure; SameSite=Lax');For webhook-only clients (no browser is waiting for the response), cookie setting has no effect — the webhook sender does not store cookies.
Testing custom clients
Section titled “Testing custom clients”The sGTM Preview mode does not easily simulate incoming POST requests from external systems. Use these approaches instead:
Postman or curl for initial testing:
# Test your custom client endpoint directlycurl -X POST https://collect.yoursite.com/webhooks/stripe \ -H "Content-Type: application/json" \ -H "stripe-signature: test_signature_value" \ -d '{ "type": "payment_intent.succeeded", "data": { "object": { "id": "pi_test_123", "amount": 9999, "currency": "usd", "metadata": {"ga_client_id": "123456789.1711900000"} } } }'Preview Header technique for sGTM Preview: When sending test requests via curl/Postman, you can attach a sGTM preview header to route the request through Preview mode:
- Start a sGTM Preview session
- Copy the
x-gtm-server-previewheader value shown in Preview - Include it in your curl/Postman request:
Terminal window curl -X POST https://collect.yoursite.com/webhooks/stripe \-H "x-gtm-server-preview: YOUR_PREVIEW_HEADER_VALUE" \-H "Content-Type: application/json" \-d '...' - The request appears in the sGTM Preview panel as if it came from a browser
This technique makes custom client debugging dramatically easier.
Common mistakes
Section titled “Common mistakes”Not claiming the request before returning. If your client’s claiming logic fails (wrong path, wrong method), the client should return without calling claimRequest(). If you call claimRequest() and then return without calling runContainer(), you have claimed the request but never processed it — no tags fire, and the caller gets no response.
Parsing JSON inside a conditional block without error handling. JSON.parse() throws on invalid input. Always wrap JSON parsing in try/catch and return a 400 response if parsing fails.
Not calling returnResponse(). If you call claimRequest(), you take ownership of the HTTP response. You must call returnResponse() at some point, or the caller’s connection hangs until timeout.
Putting sensitive data in the Event Model unnecessarily. The Event Model is accessible to all tags in the container. Do not put raw credit card numbers, passwords, or other credentials in the Event Model, even temporarily.