Google Analytics 4
Server-side GA4 Measurement Protocol integration. The tracking-ga app sends a purchase event when an order is confirmed and a refund event as units are returned, so revenue is recorded even when the browser tag is blocked — and stays correct after returns.
How it works
| Trigger | Rule | Reactor | Event |
|---|---|---|---|
Order becomes confirmed | OnOrderConfirmed | ga.purchase.create | purchase |
| Order's returned quantity grows | OnOrderReturned | ga.refund.create | refund |
Both reactors load the order, map it to the Measurement Protocol payload, and POST it to every GA property configured for the order's channel.
Properties and channel routing
Properties are managed in the portal under GA Properties, in the system section of the navigation hub.
| Field | Meaning |
|---|---|
| Measurement ID | G-XXXXXXXXXX of the data stream. Required. |
| API Secret | Measurement Protocol API secret. Required, stored as a secret. |
| Receive all channels | On ⇒ receives every order. This is what makes a master property. |
| Channels | The channel keys this property receives, when not receiving all. |
| Region | Optional. region1 keeps this property's collection in the EU. |
| Enabled | Off ⇒ configured but not sending. |
| Label | Optional display name. |
It's possible to have overlapping channels, resulting in the same order reporting to multiple properties.
Worked example
A tenant with Swedish and Norwegian storefronts and a group-level roll-up:
| Property | Routing | Receives |
|---|---|---|
master | Receive all channels | Every order |
se | Channels: b2c_se | Swedish orders |
no | Channels: b2c_no | Norwegian orders |
A Swedish order is reported twice — to master and to se. A Norwegian order goes to master and no. Adding a third market means adding one property; the master keeps working untouched, because it names no channels.
Resolution rules
- An order goes to every enabled property that either receives all channels or names the order's channel.
- A property missing its measurement id or API secret is skipped, so one half-configured property never stops the others from being reported.
- If no property matches the order's channel, nothing is sent and the job result says so.
There is no default property
The app reports nothing until at least one property exists — installing it is not enough. A single-site tenant creates one property with Receive all channels switched on.
Registry layout
The portal view is a front-end for registry entries, so properties can equally be provisioned from a manifest or the API. Each property is a group of entries under apps/tracking-ga/properties/<key>/, one entry per field:
apps/tracking-ga/properties/master/measurementId = 'G-AAAAAAAAAA'
apps/tracking-ga/properties/master/apiSecret = •••• (secret)
apps/tracking-ga/properties/master/allChannels = true
apps/tracking-ga/properties/se/measurementId = 'G-BBBBBBBBBB'
apps/tracking-ga/properties/se/apiSecret = •••• (secret)
apps/tracking-ga/properties/se/channels = ['b2c_se']
apps/tracking-ga/properties/se/region = 'region1'One entry per field, rather than one object per property, is what lets apiSecret be flagged as a secret in its own right. The app discovers properties by enumerating the measurementId entries, so a property exists as soon as it has one.
The property key is used in the per-property refund marker, so it is restricted to lowercase letters, digits and underscores.
Attribution
GA4 ties a server-side event to a shopper through the client_id its browser tag stores in the _ga cookie. The storefront captures it and stamps it on the cart; Commerce forwards it onto the order:
| Meaning | Cookie | Order field |
|---|---|---|
| GA client id | _ga | cart:tracking:gaClientId |
| GA session id | _ga_<container-id> | cart:tracking:gaSessionId |
See Storefront Integration for how to capture and send them.
An order with no client id is not tracked
Both reactors skip such orders quietly. Sending one under a made-up client id would report the sale as a brand-new user with no campaign or session attribution, quietly corrupting acquisition reports — so the app declines to guess. The job result reads Order … has no GA client id; skipping.
The session id is optional but recommended: with it, the event is attributed to the session the purchase actually happened in, so campaign, geography and device dimensions come from that visit rather than the client's latest state. GA accepts it within 24 hours of the session starting.
De-duplication
transaction_id is the order number. GA4 de-duplicates purchases on it, so a browser tag firing its own purchase for the same order collapses into one transaction rather than double-counting revenue. No coordination between the tag and the server is needed beyond both using the order number.
The purchase event
| Parameter | From |
|---|---|
transaction_id | Order number |
currency | Order currency |
value | Goods total, excl. tax and excl. shipping |
tax | orderTaxTotal |
shipping | Net shipping across the order's deliveries |
coupon | First commerce_couponCodes value, if any |
items[] | One per order line: item_id, item_name, price, quantity |
user_id | Order's customer number, when set |
value follows GA4's spec — the sum of price × quantity across items. Tax and shipping travel in their own parameters so reporting shows them separately instead of inflating revenue. Line sales totals already carry any discount, so the value reflects what the shopper actually paid.
The refund event
Returns are reported as they happen, from any source — the portal, an ERP integration, or Returns resolving a claim — because they all land as return on the order.
The event carries items, which GA4 treats as a partial refund. That is what a per-line return always is, even when every line happens to be returned. value and tax come from the returns' own refunded amounts, on the same net basis as the purchase.
Idempotency, per property
GA4 does not de-duplicate refunds — two identical refund events are two deductions from revenue. The app therefore tracks what it has already reported, and because the same refund goes to several properties, it tracks that per property. Each return carries one marker per property that received it:
return.dynamic:
gaRefundSentAt_master = 2026-08-10T09:15:00Z
gaRefundSentAt_se = 2026-08-10T09:15:00ZA shared marker would not survive a partial failure. If master accepts the event and se times out, a single marker either records the refund as done — losing it from se forever — or leaves it undone and double-deducts from master on retry. With per-property markers, each run computes each property's own unsent set, so a retry sends exactly what is still missing, to exactly the properties still missing it.
A property's marker is written only after that property accepts the event, so a failed send is retried in full for that property alone. Nothing is marked in Debug Mode, since those events never reach the reports.
Separate top-level field names also make the markers collision-safe, because setDynamicFields merges per top-level key.
Settings
Measurement ids, API secrets, regions and channel routing belong to individual properties. Only genuinely global switches are app settings:
| Setting | Description |
|---|---|
enabled | Master switch. When off, the reactors do nothing. |
debugMode | Routes events to Google's validation server instead of collecting them. |
trackRefunds | When off, only purchases are reported. |
The app is inert until enabled is on and at least one property is configured, so installing it has no effect until it is set up.
Verifying
Turn on Debug Mode. Events then go to Google's validation server instead of your reports, and the job result carries the validationMessages array — an empty array means the payload is valid. Events sent in this mode also carry debug_mode, so they appear in Admin → DebugView.
With Debug Mode off, events are collected normally and show up under Reports → Realtime within seconds.
The job result names every property it reached, so a fan-out is visible at a glance:
Sent purchase event for order LS123456 to 2 GA property/properties: master sent; se sentIf one property fails the job fails and is retried, but the result still reports each property's own outcome — so a broken API secret on one property is immediately distinguishable from a systemic problem.
See Also
- Conversion Tracking — the app family and shared conventions
- Storefront Integration — capturing the GA client and session ids
- Cart Dynamic Fields — how the ids reach the order