Server-Side GTM: CRM Enrichment with Firestore and BigQuery

Contents
Server-side enrichment adds selected backend attributes to a measurement event, such as a previously calculated customer-value segment. Internal CRM fields can then remain outside the browser. Moving processing to a server does not make data anonymous or automatically suitable for advertising platforms.
Less browser data, explicit boundaries
A customer number or hashed identifier remains personal or pseudonymized data when a link to an individual is possible. Hashing is not encryption or a general proof of anonymity. Session tokens can be particularly sensitive: access tokens and authentication cookies do not belong in analytics events.
A suitable design separates a measurement identifier from authentication. The server must verify the permitted link to a customer account. A freely editable user_id in a public browser request must not select arbitrary CRM records. Correct identifier syntax alone does not establish authorization.
| Stage | Responsibility |
|---|---|
| Browser | Send required event fields and, where permitted, a pseudonymous measurement identifier. |
| Verified backend | Establish identity, event authorization and the permitted enrichment purpose. |
| Enrichment database | Provide a small set of approved attributes with an update timestamp. |
| Destination tag | Send allowed fields in the destination schema under the required consent rules. |

Firestore lookups and BigQuery calculations
Firestore is suitable for document lookups by key. Response time depends on factors including region, networking and load. Sub-millisecond or zero-delay performance is not guaranteed for an entire ssGTM request. Typical and tail latency need measurement under realistic load.
BigQuery provides analytical calculations, for example a customer-value segment derived from order history. A separate monitored export job writes approved results into Firestore. Connecting the products does not create that export automatically. Calculation freshness, export failures and deletions need explicit handling. The request path then reads prepared values instead of running a complex warehouse query for every event.
A deliberately limited lookup example
The following code belongs in a custom server-side variable; it is not a complete template. Three template fields are required: projectId as fixed project configuration, authorizedCustomerKey from a verified server-side mapping, and lookupAllowed as a boolean decision from verified authorization and consent logic. Copying those values directly from browser fields does not meet the prerequisite.
The separate enrichment collection contains only approved attributes. The exporter writes updated_at_ms as Unix time in milliseconds, not a Firestore Timestamp object. Segment values and the 24-hour freshness limit are example policies that need alignment with the actual data model.
const Firestore = require('Firestore');
const getType = require('getType');
const queryPermission = require('queryPermission');
const getTimestampMillis = require('getTimestampMillis');
// Template inputs: trusted server-side mapping, never raw browser fields.
if (data.lookupAllowed !== true) return {status: 'not_allowed'};
const key = data.authorizedCustomerKey;
if (getType(key) !== 'string' || key.length < 16 || key.length > 128) {
return {status: 'invalid_key'};
}
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
for (let i = 0; i < key.length; i++) {
if (alphabet.indexOf(key.charAt(i)) < 0) return {status: 'invalid_key'};
}
if (getType(data.projectId) !== 'string' || data.projectId.length === 0) {
return {status: 'invalid_project'};
}
const path = 'enrichment/' + key;
if (!queryPermission('access_firestore', 'read', {projectId: data.projectId, path: path})) {
return {status: 'permission_denied'};
}
const maxAgeMs = 86400000; // Example freshness policy: 24 hours.
return Firestore.read(path, {
projectId: data.projectId
}).then((result) => {
const record = result.data;
if (getType(record) !== 'object' || record === null) {
return {status: 'invalid_record'};
}
const timestamp = record.updated_at_ms;
const now = getTimestampMillis();
if (getType(timestamp) !== 'number' || !(timestamp > 0) || timestamp > now) {
return {status: 'invalid_timestamp'};
}
if (now - timestamp > maxAgeMs) return {status: 'stale'};
const segment = record.ltv_segment;
if (segment !== 'low' && segment !== 'medium' && segment !== 'high') {
return {status: 'invalid_segment'};
}
return {status: 'ok', attributes: {ltv_segment: segment}};
}, (error) => {
return {status: error && error.reason === 'not_found' ? 'not_found' : 'lookup_error'};
});
Firestore.read resolves to an object with id and data; document fields are under result.data. A missing document rejects with reason: 'not_found'. The code separates missing, stale, invalid and technically unavailable data. Unknown values do not become a zero margin or a standard customer.
A destination tag may consume explicitly mapped attributes only when status === 'ok'. The internal status object is not an outgoing payload. A successful lookup also does not replace destination-specific permission. Each event needs a defined failure policy: send an already permitted base event without extra fields, or suppress forwarding.
Permissions and execution time
The built-in ssGTM Firestore API currently documents Native mode and only the (default) database. Isolation can use a separate project with its own default database. The template needs only read access through access_firestore for the specific project and intended path. Template restrictions complement IAM rather than replacing IAM.
The runtime service needs a dedicated identity with minimal read permissions. The export job uses a separate writing identity. IAM controls these server accesses; browser Security Rules do not protect this path. A collection with few fields and a separate project boundary reduce the data exposed to a compromised service.
A lookup extends the processing time of dependent tags. Whether the browser response also waits depends on the client and its response handling. The documented Firestore.read options do not include a custom timeout; unknown options are ignored. Sandboxed JavaScript promises have a documented five-second timeout. That limit is not a substitute for a deliberately short response budget. Stricter deadlines may require a separate lookup service or an asynchronous design.
Destination and operational checks
GA4 and Meta CAPI have different data models and requirements. A hash does not authorize arbitrary personal information in event parameters. Google offers specifically configured user-provided data features; those differ from ordinary User-ID and custom parameters. Margins and segments should reach only destinations that actually require the information.
Operational checks cover hit rate, missing documents, stale exports, technical errors and latency distribution. Aggregate counters avoid unnecessary CRM contents in logs. Costs include reads, export writes, storage and potentially network traffic. The API cache is scoped to the current request or container execution, rather than acting as a global customer cache.
Questions and answers
Why avoid a zero fallback?
An unknown value and a genuine zero have different meanings. A fallback can distort analysis. An error status and an omitted extra field preserve the distinction.
Does server processing guarantee no data leaks?
No. Browser responses, debug output, logs, exports and outgoing requests remain possible data paths. The concrete configuration and transmitted fields require inspection.
What has been tested in the example?
Twenty-nine isolated checks with simulated APIs cover input boundaries, read restrictions, missing and stale records, and the field allowlist. Those checks do not establish GTM sandbox compatibility, real IAM permissions, load behavior or vendor payload validity.