Skip to content

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

TriggerRuleReactorEvent
Order becomes confirmedOnOrderConfirmedga.purchase.createpurchase
Order's returned quantity growsOnOrderReturnedga.refund.createrefund

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.

FieldMeaning
Measurement IDG-XXXXXXXXXX of the data stream. Required.
API SecretMeasurement Protocol API secret. Required, stored as a secret.
Receive all channelsOn ⇒ receives every order. This is what makes a master property.
ChannelsThe channel keys this property receives, when not receiving all.
RegionOptional. region1 keeps this property's collection in the EU.
EnabledOff ⇒ configured but not sending.
LabelOptional 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:

PropertyRoutingReceives
masterReceive all channelsEvery order
seChannels: b2c_seSwedish orders
noChannels: b2c_noNorwegian 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:

MeaningCookieOrder field
GA client id_gacart: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

ParameterFrom
transaction_idOrder number
currencyOrder currency
valueGoods total, excl. tax and excl. shipping
taxorderTaxTotal
shippingNet shipping across the order's deliveries
couponFirst commerce_couponCodes value, if any
items[]One per order line: item_id, item_name, price, quantity
user_idOrder'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:00Z

A 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:

SettingDescription
enabledMaster switch. When off, the reactors do nothing.
debugModeRoutes events to Google's validation server instead of collecting them.
trackRefundsWhen 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 sent

If 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

© 2026 Hantera AB. All rights reserved.