Skip to content

Storefront Integration

Server-side conversion tracking needs one thing the server cannot work out for itself: who the shopper is, in the ad platform's own terms. That identity lives in a cookie or a landing-page query parameter, so the storefront has to capture it and hand it to Hantera.

This page covers the whole path: reading the value in the browser, stamping it on the cart, and reading it back off the order.

browser cookie / query param
        │  storefront reads it

POST …/set-field/tracking:<key>      →  cart.dynamic['field:tracking:<key>']
        │  cart completes (apps.commerce)

order.dynamic['cart:tracking:<key>'] →  tracking app reads it

Without this, tracking apps report nothing

Each app treats a missing identifier as "this order is not attributable" and skips the order rather than guessing. Getting this step right is what makes the rest work.

1. Read the identifier in the browser

The values live where the platform's own tag put them.

Google Analytics

GA4 stores the client id in the _ga cookie as GA1.1.<client_id>, and the session id inside _ga_<container-id> (the container is your measurement id without the G- prefix):

js
function readCookie(name) {
  return document.cookie
    .split('; ')
    .find((row) => row.startsWith(name + '='))
    ?.split('=')[1]
}

// _ga = GA1.1.1234567890.1712345678  →  client id is the last two segments
const gaClientId = readCookie('_ga')?.split('.').slice(-2).join('.')

// _ga_ABCDEF1234 = GS1.1.1712345678.1.1.1712345699.0.0.0  →  session id is index 2
const gaSessionId = readCookie('_ga_ABCDEF1234')?.split('.')[2]

The cookies only exist once the GA tag has run, so read them after it loads.

Meta

Meta's Pixel writes _fbp, and _fbc when the shopper arrived from an ad. If _fbc is missing but the landing URL carries fbclid, Meta's documented format is fb.1.<timestamp>.<fbclid>:

js
const fbclid = new URLSearchParams(location.search).get('fbclid')

const fbp = readCookie('_fbp')
const fbc = readCookie('_fbc') ?? (fbclid ? `fb.1.${Date.now()}.${fbclid}` : undefined)

Awin

Awin appends awc to the landing URL on an affiliate click. It is only present on that first page view, so capture it on landing and keep it (session storage, first-party cookie) until a cart exists:

js
const awc = new URLSearchParams(location.search).get('awc')
if (awc) sessionStorage.setItem('awc', awc)

2. Stamp it on the cart

Write each value with the set-field ingress, using a key under the shared tracking: namespace:

bash
POST /ingress/commerce/carts/{cartId}/set-field/tracking:gaClientId
Content-Type: application/json

{ "value": "1234567890.1712345678" }

With the Storefront SDK:

ts
await cart.setField(cartId, 'tracking:gaClientId', { value: gaClientId })
await cart.setField(cartId, 'tracking:gaSessionId', { value: gaSessionId })

The key you pass is stored as field:tracking:gaClientId — the field: prefix is added for you, and stripped again in the rendered cart's fields.

A helper that stamps everything you have, skipping what you don't:

ts
const trackingIds = {
  'tracking:gaClientId': gaClientId,
  'tracking:gaSessionId': gaSessionId,
  'tracking:fbp': fbp,
  'tracking:fbc': fbc,
  'tracking:awc': sessionStorage.getItem('awc') ?? undefined,
}

for (const [key, value] of Object.entries(trackingIds)) {
  if (value) await cart.setField(cartId, key, { value })
}

Stamp early, not at checkout

cart-to-order reads the cart's state at completion time, and completion is often triggered by a PSP callback rather than by your code. Write the fields as soon as the cart exists — right after createCart is the safest point. Values can be overwritten later; a value that was never written is lost.

What you get for free

Commerce captures client context from the request headers when the cart is created, so you don't have to send it:

MeaningCart fieldOrder field
Client user-agentfield:client:userAgentcart:client:userAgent
Client IPfield:client:ipcart:client:ip
Storefront originfield:storefrontUrlcart:storefrontUrl

For these to be right, create the cart from the shopper's browser (or forward the shopper's User-Agent, IP and Origin if you proxy it server-side). Otherwise Hantera records your server rather than the shopper.

3. It lands on the order

When the cart completes, apps.commerce projects every field:<key> onto the order as cart:<key>:

cart.dynamic:
  field:tracking:gaClientId  = '1234567890.1712345678'
  field:tracking:fbp         = 'fb.1.1712345678.1098765432'

→ order.dynamic:
  cart:tracking:gaClientId   = '1234567890.1712345678'
  cart:tracking:fbp          = 'fb.1.1712345678.1098765432'

Nothing else is needed — the tracking apps pick them up from there.

4. Reading them back

From a rule or reactor, off the order's dynamic map:

filtrera
let gaClientId = order.dynamic->'cart:tracking:gaClientId' match
  (v: text) when v != '' |> v
  |> nothing

Match as text and treat blank as absent: dynamic values are stored as JSON, so a field that was written with an empty string is still present.

To surface one in the portal or the query graph, register it as a graph field in your app manifest:

yaml
registryEntries:
  - path: graph/order/gaClientId
    value:
      source: "dynamic->'cart:tracking:gaClientId'"

Verifying

  1. On the cart — any mutation response echoes the values back under fields (without the field: prefix):
    json
    { "fields": { "tracking:gaClientId": "1234567890.1712345678" } }
  2. On the order — check dynamic in the portal's order view, or query orders(dynamic).
  3. In the job log — if an id is missing, the tracking app says so explicitly, e.g. Order LS123456 has no GA client id; skipping.

These identifiers are personal data. The storefront is the right place to enforce consent, because it is the only layer that knows what the shopper agreed to: only stamp a field when the corresponding consent has been given, and the whole chain below it stays clean. An id that was never stamped is an order the tracking apps quietly skip.

See Also

© 2026 Hantera AB. All rights reserved.