Google Data Manager API
The Google Data Manager API destination sends conversion events from your server to Google Ads, Display & Video 360 and Google Analytics 4 through one API endpoint.
Google Data Manager API is a server destination in the walkerOS flow:
Sends conversion events server-side to Google Ads, Display & Video 360, and GA4 through a single unified API.
Installation
npm install @walkeros/collector @walkeros/server-destination-datamanager- Integrated
- Bundled
import { startFlow } from '@walkeros/collector';
import destinationDatamanager from '@walkeros/server-destination-datamanager';
await startFlow({
destinations: {
datamanager: {
code: destinationDatamanager,
config: {
settings: {
destinations: [
{
operatingAccount: {
accountId: '123-456-7890',
accountType: 'GOOGLE_ADS',
},
productDestinationId: 'AW-CONVERSION-123',
},
],
},
},
},
},
});
A complete flow.json with this destination:
{
"version": 4,
"flows": {
"default": {
"config": {
"platform": "server"
},
"destinations": {
"datamanager": {
"package": "@walkeros/server-destination-datamanager",
"config": {
"settings": {
"destinations": [
{
"operatingAccount": {
"accountId": "123-456-7890",
"accountType": "GOOGLE_ADS"
},
"productDestinationId": "AW-CONVERSION-123"
}
]
}
}
}
}
}
}
}The destination authenticates with Application Default Credentials: the GOOGLE_APPLICATION_CREDENTIALS environment variable, or the metadata server on Google Cloud. To use a service account instead, set config.credentials to its client_email and private_key, or settings.keyFilename to the path of its key file. If no credentials are found, the destination does not start: every push answers failed with the code config, and the collector logs destination init failed.
The destination sends an event only when its mapped data carries a transactionId; any other event fails with the code missing-input and one ERROR line (transactionId is required). The quickstart maps none, so add one, for example data: { map: { transactionId: 'id' } } in config to use the walkerOS event ID. For an order that the Google tag also reports, map the order ID the tag sends instead, like data.id in the order's rule: Google deduplicates the two by transactionId within one conversion action, so the values must match (additional data sources). With a Google Analytics destination (accountType: 'GOOGLE_ANALYTICS_PROPERTY'), an event without an eventName fails the same way.
Configuration
This destination uses the standard destination config wrapper (consent, data, env, id, ...). For the shared fields see destination configuration. Package-specific fields live under config.settings and are listed below.
Settings
| Property | Type | Description | More |
|---|---|---|---|
credentials | credentials | Service account credentials (client_email + private_key). Prefer config.credentials; set here, they are used and the destination logs a WARN line. | |
client_email | string | Service account email | |
private_key | string | Service account private key (PEM format) | |
keyFilename | string | Path to service account JSON file. For local development or environments with filesystem access. | |
scopes | Array<string> | OAuth scopes for Data Manager API. Defaults to datamanager scope. | |
destinations | Array<object> | Array of destination accounts and conversion actions (max 10) | |
eventSource | Event source for all events. Defaults to WEB. Values: WEB, APP, IN_STORE, PHONE, OTHER | ||
batchSize | integer | Has no effect: every event is sent in its own request (accepts 1 to 2000) | |
batchInterval | integer | Has no effect: every event is sent in its own request (accepts milliseconds, like 5000) | |
validateOnly | boolean | If true, validate request without ingestion (testing mode) | |
url | string | Override API endpoint for testing | |
consent | consent | Request-level consent, sent with every request and applied to each event that carries no consent of its own | |
adUserData | Consent for data collection and use | ||
adPersonalization | Consent for ad personalization | ||
testEventCode | string | Test event code for debugging | |
userData | Record<string, any> | Guided helper: User data mapping for all events | |
userId | any | Guided helper: First-party user ID for all events | |
clientId | any | Guided helper: GA4 client ID for all events | |
appInstanceId | any | Guided helper: GA4 app instance ID (Firebase) for all events | |
sessionAttributes | any | Guided helper: Privacy-safe attribution for all events | |
consentAdUserData | string | boolean | Consent mapping: Field name from event.consent (like 'marketing') or static boolean value | |
consentAdPersonalization | string | boolean | Consent mapping: Field name from event.consent (like 'targeting') or static boolean value | |
ip | any | boolean | Client IP, sent as eventDeviceInfo.ipAddress. Resolves against { ingest, event }. Default: ["ingest.ip", "event.user.ip"]. false switches it off. A mapped ipAddress wins. | |
userAgent | any | boolean | Client user agent, sent as eventDeviceInfo.userAgent. Resolves against { ingest, event }. Default: ["ingest.userAgent", "event.user.userAgent"]. false switches it off. A mapped userAgent wins. |
Mapping
This package does not define custom rule-level settings. For the standard rule fields (consent, condition, data, batch, name, policy) see mapping.
Examples
Page view
A page view is sent to Data Manager as a page_view event tied to the walker user id.
{
"name": "page view",
"data": {
"title": "Pricing",
"url": "https://example.com/pricing"
},
"context": {
"dev": [
"test",
1
]
},
"globals": {
"pagegroup": "docs"
},
"custom": {
"completely": "random"
},
"user": {
"id": "visitor-55"
},
"nested": [
{
"entity": "child",
"data": {
"is": "subordinated"
}
}
],
"consent": {
"functional": true
},
"id": "ev-1700000902000",
"trigger": "load",
"entity": "page",
"action": "view",
"timestamp": 1700000902000,
"timing": 3.14,
"source": {
"count": 1,
"trace": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"type": "express",
"platform": "server"
}
}{
"name": "page_view",
"data": {
"map": {
"transactionId": "id",
"eventName": {
"value": "page_view"
},
"userId": "user.id"
}
}
}fetch("https://datamanager.googleapis.com/v1/events:ingest", {
"method": "POST",
"headers": {
"Authorization": "Bearer ya29.c.test_token",
"Content-Type": "application/json"
},
"body": "{\"events\":[{\"eventTimestamp\":\"2023-11-14T22:28:22.000Z\",\"transactionId\":\"ev-1700000902000\",\"userId\":\"visitor-55\",\"eventName\":\"page_view\",\"eventSource\":\"WEB\"}],\"destinations\":[{\"operatingAccount\":{\"accountId\":\"123-456-7890\",\"accountType\":\"GOOGLE_ADS\"},\"productDestinationId\":\"AW-CONVERSION-123\"}],\"encoding\":\"HEX\"}"
})Lead
A demo request form submission is sent to Data Manager as a generate_lead conversion with a hashed email.
{
"name": "form submit",
"data": {
"type": "demo-request"
},
"context": {
"dev": [
"test",
1
]
},
"globals": {
"lang": "elb"
},
"custom": {
"completely": "random"
},
"user": {
"email": "prospect@example.com"
},
"nested": [
{
"entity": "child",
"data": {
"is": "subordinated"
}
}
],
"consent": {
"functional": true
},
"id": "ev-1700000901000",
"trigger": "test",
"entity": "form",
"action": "submit",
"timestamp": 1700000901000,
"timing": 3.14,
"source": {
"count": 1,
"trace": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"type": "express",
"platform": "server"
}
}{
"name": "generate_lead",
"data": {
"map": {
"transactionId": "id",
"eventName": {
"value": "generate_lead"
},
"conversionValue": {
"value": 10
},
"currency": {
"value": "USD"
},
"email": "user.email"
}
}
}fetch("https://datamanager.googleapis.com/v1/events:ingest", {
"method": "POST",
"headers": {
"Authorization": "Bearer ya29.c.test_token",
"Content-Type": "application/json"
},
"body": "{\"events\":[{\"eventTimestamp\":\"2023-11-14T22:28:21.000Z\",\"transactionId\":\"ev-1700000901000\",\"userData\":{\"userIdentifiers\":[{\"emailAddress\":\"395ec5f334be0ab5b28568a1e7f6ed5ea80e443fb1ce3d803340586a3df46642\"}]},\"conversionValue\":10,\"currency\":\"USD\",\"eventName\":\"generate_lead\",\"eventSource\":\"WEB\"}],\"destinations\":[{\"operatingAccount\":{\"accountId\":\"123-456-7890\",\"accountType\":\"GOOGLE_ADS\"},\"productDestinationId\":\"AW-CONVERSION-123\"}],\"encoding\":\"HEX\"}"
})Purchase
A completed order is posted to Google Data Manager as a purchase conversion with hashed user identifiers.
{
"name": "order complete",
"data": {
"id": "ORD-600",
"value": 149.99,
"currency": "EUR"
},
"context": {
"shopping": [
"complete",
0
]
},
"globals": {
"pagegroup": "shop"
},
"custom": {
"completely": "random"
},
"user": {
"id": "user-abc",
"email": "buyer@example.com"
},
"nested": [
{
"entity": "product",
"data": {
"id": "ers",
"name": "Everyday Ruck Snack",
"color": "black",
"size": "l",
"price": 420
},
"context": {
"shopping": [
"complete",
0
]
},
"nested": []
},
{
"entity": "product",
"data": {
"id": "cc",
"name": "Cool Cap",
"size": "one size",
"price": 42
},
"context": {
"shopping": [
"complete",
0
]
},
"nested": []
},
{
"entity": "gift",
"data": {
"name": "Surprise"
},
"context": {
"shopping": [
"complete",
0
]
},
"nested": []
}
],
"consent": {
"functional": true
},
"id": "ev-1700000900000",
"trigger": "load",
"entity": "order",
"action": "complete",
"timestamp": 1700000900000,
"timing": 3.14,
"source": {
"count": 1,
"trace": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"type": "express",
"platform": "server"
}
}{
"name": "purchase",
"data": {
"map": {
"transactionId": "data.id",
"conversionValue": "data.value",
"currency": {
"key": "data.currency",
"value": "USD"
},
"eventName": {
"value": "purchase"
},
"userId": "user.id",
"email": "user.email"
}
}
}fetch("https://datamanager.googleapis.com/v1/events:ingest", {
"method": "POST",
"headers": {
"Authorization": "Bearer ya29.c.test_token",
"Content-Type": "application/json"
},
"body": "{\"events\":[{\"eventTimestamp\":\"2023-11-14T22:28:20.000Z\",\"transactionId\":\"ORD-600\",\"userId\":\"user-abc\",\"userData\":{\"userIdentifiers\":[{\"emailAddress\":\"6a6c26195c3682faa816966af789717c3bfa834eee6c599d667d2b3429c27cfd\"}]},\"conversionValue\":149.99,\"currency\":\"EUR\",\"eventName\":\"purchase\",\"eventSource\":\"WEB\"}],\"destinations\":[{\"operatingAccount\":{\"accountId\":\"123-456-7890\",\"accountType\":\"GOOGLE_ADS\"},\"productDestinationId\":\"AW-CONVERSION-123\"}],\"encoding\":\"HEX\"}"
})When it fails
- order without transaction id: An order complete event without a transaction id cannot be sent to the Data Manager API: the push fails with missing-input instead of counting as delivered. Reported as
failedwith codemissing-input.
Failures
A response outside the 2xx range, a 2xx answer that reports validation errors or cannot be read, a network error, a failed token request, or no answer within the collector's 10-second timeout fails the event with the code transport or timeout: the event goes to the dead-letter queue and the collector logs one ERROR line without the response body. A rule with silent: true sends nothing.
Guided mapping helpers
Define common fields once in Settings instead of repeating them in every event mapping. Event mappings always override Settings helpers.
await startFlow({
destinations: {
datamanager: {
code: destinationDatamanager,
config: {
credentials: { /* ... */ },
settings: {
destinations: [...],
// Guided helpers (apply to all events)
userData: {
email: 'user.email',
phone: 'data.phone',
},
userId: 'user.id',
clientId: 'user.device', // GA4 web stream
appInstanceId: 'user.appInstanceId', // GA4 app stream (Firebase)
sessionAttributes: 'context.sessionAttributes',
// Consent mapping
consentAdUserData: 'marketing',
consentAdPersonalization: 'personalization',
},
},
},
},
});Consent mapping: a string names a key of the event's consent, a boolean is a static value. The collector hands a destination only the consent states an event grants, so a string sends CONSENT_GRANTED when the event grants that key and nothing for it when the event denies it: Google never receives CONSENT_DENIED from an event's consent. A static false sends CONSENT_DENIED. The request-level settings.consent fills in for every event that carries no consent of its own, so with settings.consent set to CONSENT_GRANTED, an event whose consent denies the mapped key is sent as granted.
User data
The destination reads these keys from the mapped data (Settings helpers, then config.data, then the rule's data):
email,phone,firstNameandlastName: normalized and SHA-256 hashed.regionCodeandpostalCode: sent unhashed, as Google requires.userId,clientId,appInstanceId,gclid,gbraid,wbraidandsessionAttributes: sent as mapped.transactionId,conversionValue,currency,eventName,cartData,storeId,eventSource,adUserData,adPersonalization,ipAddressanduserAgent: the event's conversion, consent and device fields, not hashed.
Any other key, a list, and a user_data or userData object in a rule's data are dropped without a log line. There is no doNotHash: a value that is already a hash is hashed again.
Normalization follows Google's formatting rules with these gaps today: for gmail.com and googlemail.com addresses dots are removed but a +alias is kept; spaces inside an email are kept; a family name ending in v, iv, ii or iii without a space before it loses those letters (Ivanov is hashed as ivano); and a phone number without a leading + gets +1 when it has 10 digits or fewer, or only a + otherwise. Map phone numbers in E.164 format (+491701234567).
Client IP and user agent
The destination sends the client IP and user agent as eventDeviceInfo.ipAddress
and eventDeviceInfo.userAgent by default. The ip and userAgent settings
resolve against { ingest, event } and default to ["ingest.ip", "event.user.ip"]
and ["ingest.userAgent", "event.user.userAgent"], so they fill in whenever the
source provides them (see the config.ingest recipe on the
Express source). A value your event mapping sets
for ipAddress or userAgent wins; a value that resolves to nothing is not
sent. Set either to false to turn it off:
settings: {
destinations: [...],
ip: false, // never send the client IP
userAgent: 'ingest.userAgent', // request only, ignore event.user
}Consent
The quickstart sets no config.consent, so Google receives every event with its user data, whatever consent the event carries, and a denial is not passed on (see Consent mapping above). To send only events with consent, add consent: { marketing: true } to config, next to settings (with several keys listed, one granted key is enough). An event without that consent is skipped or queued, depending on the collector's consentDenied. Server flows built by the CLI skip it and count it in skipped. With startFlow it waits in the destination's queue by default: a later event that carries consent does not release it, and only a walker consent command does, which then sends the waiting events of every user at once. Pass consentDenied: 'skip' to startFlow to skip such events instead (see Gate a destination with consent and consent-denied events on a server).
Simulate
walkeros push --simulate destination.<id> uses the package's mock env for the
auth client and fetch, so no token is requested and nothing reaches Google.
The output shows the fetch call with the request body the destination would
send.
Store sales (IN_STORE)
For physical store conversions, set eventSource: 'IN_STORE' on the destination
and map a storeId in the event mapping. The destination wraps it into the
API's required eventLocation.storeId shape.
mapping: {
order: {
complete: {
name: 'purchase',
data: {
map: {
transactionId: 'data.id',
conversionValue: 'data.value',
currency: 'data.currency',
storeId: 'data.storeId',
email: 'user.email', // hashed PII still applies
},
},
},
},
}