Skip to content

Building Custom Clients: A Tutorial

The built-in GA4 client covers one shape of incoming traffic: browser-originated Measurement Protocol v2 hits to /g/collect and /mp/collect. Everything else — a CRM webhook from HubSpot, a server-to-server ping from your own backend, a mobile SDK emitting a proprietary schema, a third-party event stream — needs a client you write yourself.

This article is the tutorial. What Are Clients? explains the concept; Custom Clients is the reference. Here, we walk through building one end-to-end, with the lifecycle made explicit and a worked CRM receiver you can adapt.

Valid as of April 2026, Server-side GTM container version 2.0+.

A client template executes in three phases, and the phase boundaries matter. If you put logic in the wrong phase — especially if you call claimRequest() too late or returnResponse() too early — the request hangs, double-fires, or silently drops.

Everything before claimRequest() is the claiming decision. The client reads request metadata — path, method, headers, sometimes body — and decides whether this request belongs to it.

const claimRequest = require('claimRequest');
const getRequestPath = require('getRequestPath');
const getRequestMethod = require('getRequestMethod');
// Decision: is this our request?
if (getRequestPath() !== '/webhooks/crm' || getRequestMethod() !== 'POST') {
return; // Not ours — exit; next client in priority order evaluates.
}
// Claim it. No other client will be evaluated.
claimRequest();

claimRequest() must be called synchronously — not inside an sendHttpGet callback, not inside runContainer’s completion handler. Once called, the request is yours and yours alone.

Read the body, parse it, map its fields into a plain object that becomes the Event Model. The Event Model is not a class or a protected structure — it is a JavaScript object passed to runContainer(). Conventionally it has event_name, client_id, and whatever other fields your downstream tags need, but the runtime does not enforce a schema.

const getRequestBody = require('getRequestBody');
const JSON = require('JSON');
const raw = getRequestBody();
let payload;
try {
payload = JSON.parse(raw);
} catch (e) {
// Invalid body — respond 400 and stop.
return respondWith(400, 'Invalid JSON');
}
const eventModel = {
event_name: mapCrmType(payload.type),
client_id: payload.ga_client_id || ('crm_' + payload.contact_id),
contact_id: payload.contact_id,
// ...map other fields you care about
};

Hand the Event Model to runContainer(). Inside its completion callback, send the HTTP response. If you do not call returnResponse(), the connection hangs until Cloud Run’s request timeout (60 seconds by default) — the webhook sender sees a timeout and retries.

const runContainer = require('runContainer');
const setResponseStatus = require('setResponseStatus');
const setResponseBody = require('setResponseBody');
const returnResponse = require('returnResponse');
runContainer(eventModel, () => {
setResponseStatus(200);
setResponseBody(JSON.stringify({received: true}));
returnResponse();
});
Request arrives
Phase 1 Claim claimRequest() — synchronous
Phase 2 Build Parse body, construct event model object
Phase 3 Run + respond runContainer(..., () => { ...; returnResponse(); })

Those three phases are the entire template contract. The rest is detail.

When to write a client vs. use the GA4 client

Section titled “When to write a client vs. use the GA4 client”

Do not build a custom client reflexively. Each one adds code to maintain, a permissions surface to audit, and a priority slot to coordinate. The GA4 client handles more cases than people assume.

ScenarioGA4 clientCustom client
CRM webhook (HubSpot, Salesforce, Pipedrive)NoYes
Stripe, Shopify, other payment/commerce webhooksNoYes
Mobile SDK sending standard GA4 Measurement ProtocolYesNo
Your own backend emitting Measurement ProtocolYesNo
Non-standard Measurement Protocol dialectPrefer wrapping to standard MP in the emitterYes if you can’t change the emitter
Two sources sharing /events, discriminated by headerNoYes (with body or header discrimination)
Browser-originated request to /g/collectYes (default)Only override if you have a specific reason

If your emitter can be modified to send standard Measurement Protocol, that is almost always the cheaper path. Build a custom client when the emitter’s format is fixed (third-party webhooks) or when the format genuinely needs different processing.

Claiming logic decides which requests a client handles. Four patterns, in rough order of specificity.

Path-based. The common case. Match a fixed path or a prefix.

if (getRequestPath() !== '/webhooks/hubspot') return;
claimRequest();

Method + path. Reject everything except the expected method. Prevents GET /webhooks/hubspot (browser curiosity, link-preview bots) from accidentally claiming.

if (getRequestPath() !== '/webhooks/hubspot' || getRequestMethod() !== 'POST') return;
claimRequest();

Header-based discrimination. When two sources share a path.

const source = getRequestHeader('x-event-source');
if (getRequestPath() !== '/events' || source !== 'hubspot') return;
claimRequest();

Body-shape discrimination. The last resort. You have to parse the body before claiming, which wastes work if another client would have claimed first. Use only when headers and paths genuinely cannot discriminate.

if (getRequestPath() !== '/events') return;
const body = getRequestBody();
let parsed;
try { parsed = JSON.parse(body); } catch (e) { return; }
if (parsed.schema !== 'my-crm/v1') return;
claimRequest();
// Continue with the already-parsed body.

Webhook endpoints are publicly addressable. If anyone can POST to /webhooks/stripe and fire your conversion tags, anyone can poison your attribution data. Validate before claiming anything that runs runContainer().

Shared-secret header. Cheapest option. Fine for low-stakes internal services.

const getRequestHeader = require('getRequestHeader');
const auth = getRequestHeader('x-auth-token');
if (auth !== data.sharedSecret) {
claimRequest();
setResponseStatus(401);
returnResponse();
return;
}

HMAC signature. The standard for third-party webhooks. sGTM exposes computeHmac for this. Full signature handling is covered in Webhook Integrations — the short version:

const computeHmac = require('computeHmac');
const body = getRequestBody();
const received = getRequestHeader('x-signature');
const expected = computeHmac('SHA-256', data.webhookSecret, body, 'hex');
if (expected !== received) {
claimRequest();
setResponseStatus(401);
returnResponse();
return;
}

IP allowlisting. The caller’s IP is in x-forwarded-for. Cloud Run may prepend its own hops, so parse carefully — the caller is typically the first entry, not the last.

const xff = getRequestHeader('x-forwarded-for') || '';
const callerIp = xff.split(',')[0].trim();
if (!STRIPE_IP_RANGES.some(function(cidr) { return ipInCidr(callerIp, cidr); })) {
claimRequest();
setResponseStatus(403);
returnResponse();
return;
}

A full client template for receiving CRM events. POST-only, HMAC-authenticated, maps CRM event types to GA4 event names, builds an Event Model with identity fields, and logs a structured line to Cloud Logging on every claim.

Required template fields:

  • webhookSecret (text, canBeSensitive: true) — the shared signing secret.
  • expectedPath (text, default /webhooks/crm) — the path this client claims.

Required permissions:

  • read_request (path, method, body, headers)
  • access_response (status, body)
  • return_response
  • run_container
  • logging
  • compute_hmac (SHA-256)
const claimRequest = require('claimRequest');
const getRequestBody = require('getRequestBody');
const getRequestHeader = require('getRequestHeader');
const getRequestMethod = require('getRequestMethod');
const getRequestPath = require('getRequestPath');
const runContainer = require('runContainer');
const returnResponse = require('returnResponse');
const setResponseBody = require('setResponseBody');
const setResponseStatus = require('setResponseStatus');
const logToConsole = require('logToConsole');
const computeHmac = require('computeHmac');
const getTimestampMillis = require('getTimestampMillis');
const JSON = require('JSON');
// --- Phase 1: claiming decision ----------------------------------------
if (getRequestPath() !== data.expectedPath) return;
if (getRequestMethod() !== 'POST') return;
const rawBody = getRequestBody();
const receivedSig = getRequestHeader('x-signature');
const expectedSig = computeHmac('SHA-256', data.webhookSecret, rawBody, 'hex');
claimRequest();
if (!receivedSig || expectedSig !== receivedSig) {
logToConsole(JSON.stringify({
level: 'error', client: 'crm_webhook', reason: 'signature_mismatch'
}));
setResponseStatus(401);
setResponseBody('Invalid signature');
returnResponse();
return;
}
// --- Phase 2: parse and build Event Model -----------------------------
let payload;
try {
payload = JSON.parse(rawBody);
} catch (e) {
setResponseStatus(400);
setResponseBody('Invalid JSON');
returnResponse();
return;
}
if (!payload.type || (!payload.contact_id && !payload.email_sha256)) {
setResponseStatus(422);
setResponseBody('Missing type or identity');
returnResponse();
return;
}
const EVENT_NAME_MAP = {
'contact.lifecycle.customer': 'crm_customer_created',
'deal.closed_won': 'crm_deal_closed',
'contact.marketing.consent': 'crm_consent_granted'
};
const eventName = EVENT_NAME_MAP[payload.type];
if (!eventName) {
// Acknowledge unknown types without firing tags — stops retries.
setResponseStatus(200);
setResponseBody('Unmapped event type');
returnResponse();
return;
}
const eventModel = {
event_name: eventName,
client_id: payload.ga_client_id || ('crm_' + payload.contact_id),
user_data: {
email_sha256: payload.email_sha256,
external_id: payload.contact_id
},
crm_source: 'hubspot',
deal_value: payload.deal && payload.deal.amount_usd,
currency: 'USD',
event_time: Math.floor(getTimestampMillis() / 1000)
};
logToConsole(JSON.stringify({
level: 'info', client: 'crm_webhook',
event_name: eventName, contact_id: payload.contact_id
}));
// --- Phase 3: run container and respond --------------------------------
runContainer(eventModel, () => {
setResponseStatus(200);
setResponseBody(JSON.stringify({received: true, event: eventName}));
returnResponse();
});

Every custom client has a matrix of failure modes. Map each one to a specific response code and log level.

ConditionResponseLog levelWhy
Invalid JSON body400warnMalformed input; sender’s problem
Signature mismatch401errorPossible attack, or rotated secret
Missing required field422warnSender sent unexpected schema
Unknown event type200infoAcknowledge; don’t trigger retries
Tag failure inside container(already 200)error in tagHandle in tag via addEventCallback
sendHttpRequest to downstream times out(already 200)error in tagWebhook sender already ack’d

The trap to avoid: calling returnResponse() after runContainer() finishes but before the downstream tag has actually forwarded the event. That is the right design for webhook acknowledgement — you want to ack fast — but it means downstream failures never propagate back to the sender. Log them; don’t hope the sender will retry.

  1. Enable sGTM Preview. Copy the x-gtm-server-preview header value.

  2. Send a test request via curl with the preview header:

    Terminal window
    curl -X POST https://collect.yoursite.com/webhooks/crm \
    -H "Content-Type: application/json" \
    -H "X-Signature: $(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')" \
    -H "X-GTM-Server-Preview: ZW52LTF8..." \
    -d "$BODY"
  3. Watch the Preview panel. You should see: client matched, Event Model built, triggers evaluated, tags fired. If the panel shows “No client claimed”, your claiming logic rejected the request — check path, method, and signature in that order.

  4. Unit test with mock() per Testing Custom Templates. Mock getRequestPath, getRequestBody, computeHmac, and assert that claimRequest and runContainer are called with the right arguments.

Calling claimRequest() inside a callback. claimRequest() must be synchronous. If you place it inside a sendHttpGet or runContainer callback, by the time the callback fires, the request router has moved on. No other client has claimed either, so the request returns a default 200 with no processing — a silent drop.

Forgetting returnResponse(). If claimRequest() runs but returnResponse() never does, the caller waits 60 seconds and times out. The webhook sender retries. You will see this as “why are conversions doubled every minute” three weeks after deployment.

Reusing the GA4 client’s client_id namespace for CRM contacts. GA4 client_id values look like 1711900000.1234567890. If you generate client_id: '42' for contact ID 42, you create identity collisions with real GA4 clients whose random number happened to be 42. Prefix CRM-derived IDs: 'crm_42'.

Hardcoding the webhook secret. Put it in a template field, mark it canBeSensitive: true, and store the actual secret in the client configuration. Template code is version-controlled and exportable; secrets in code are secrets in the export.