LW IT Solutions logo LW IT Solutions logo LW IT Solutions
« Blog Overview /Digital Analytics/Tutorials / Uploading In-Store Purchases to GA4 With the...
Read this article in other languages:

Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key

Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key

Management Summary

In-store purchases can complement website results in Google Analytics when a verified loyalty-card connection links both systems. The Measurement Protocol is the interface used to send those purchases from the business’s server. This supports a combined view of online activity and shop sales, but a card number alone proves neither a person nor an earlier visit. Valid permission, reliable matching and the real purchase time are essential; unmatched receipts remain in the business’s own analysis.

Contents
  1. Three identifiers with different jobs
  2. A hash protects the number but does not establish permission
  3. Recording the connection during an authenticated visit
  4. A purchase as JSON
  5. Time and attribution have different limits
  6. Validation and retries
  7. Refunds and older data
  8. Questions and answers
  9. Sources

A till supplies products, amounts, and a sale time. A loyalty card may additionally point to a customer account. GA4 instead uses identifiers from online measurement. A combined report therefore needs a reliable mapping between the systems. A matching string alone proves neither a person nor an earlier session.

The Measurement Protocol is one route for sending these server events to GA4. This guide describes a deliberately limited implementation: sales with verified mappings and permitted transmission. Receipts without a suitable online identifier initially remain in the business’s own analysis.

A verified card link, an actual online identifier, and a checked purchase payload.
Each step needs its own evidence; a card alone proves no online session.

Three identifiers with different jobs

client_id identifies a browser instance on the web and is required by the web Measurement Protocol. A different browser or deleted cookies can mean a different identifier. user_id is an optional user identifier assigned by the business. A consistent User-ID can support cross-device reporting when the integration and reporting identity are suitable.

A loyalty card number starts as an identifier in the till system. Family cards, transferred cards, and reassigned numbers contradict the assumption that one card always represents one person. These cases need a separate rule; a household card cannot simply serve as an individual User-ID.

A hash protects the number but does not establish permission

An HMAC produces a repeatable value from a card number and a secret key. The same number and key produce the same result. Without the key, a list of possible card numbers cannot simply be compared with stored values. This protects the mapping; it does not automatically anonymize it. The operator can still connect it to an account.

This example uses HMAC only as an internal lookup key. An existing randomly assigned Analytics User-ID remains separate. Rotating the HMAC key then need not also change the User-ID. Consent, permitted purpose, retention, and deletion remain separate requirements.

The PHP function accepts only the assumed card format: LW and eight digits, optionally separated by spaces or hyphens at the illustrated positions. Arbitrary special characters are rejected instead of silently removed. This prevents different invalid inputs from accidentally becoming the same number during cleanup.

PHP
function card_key(string $card, string $key): string {
    if (strlen($key) < 32) {
        throw new InvalidArgumentException('HMAC key too short');
    }
    if (!preg_match('/\ALW[ -]?([0-9]{4})[ -]?([0-9]{4})\z/iD', trim($card), $m)) {
        throw new InvalidArgumentException('Invalid card format');
    }
    $canonical = 'LW' . $m[1] . $m[2];
    return hash_hmac('sha256', 'loyalty-lookup-v1:' . $canonical, $key);
}

The key must contain at least 32 cryptographically random bytes and remain outside public files. The example’s length check does not measure randomness. The till and backend need identical format and normalization rules. The function checks format, not the card’s authenticity or the account’s entitlement to it.

Recording the connection during an authenticated visit

A mapping is created only after the backend verifies the signed-in account’s entitlement to the card. Entering somebody else’s card number is insufficient. Online collection and the later linkage must also be permitted. The documented tag interface gtag('get', 'G-XXXXXXXXXX', 'client_id', callback) retrieves the client ID. A missing value or callback produces no mapping. A custom cookie parser is unnecessary.

The browser submits the identifier to an authenticated backend endpoint protected against cross-site form submissions. The backend determines the account and verified card from the session, checks inputs and authorization, and stores only the necessary connection. A client ID is not a login or proof of account ownership.

A mapping can contain the internal card value and key version, client ID, confirmation time, and validity. Withdrawal or an unlinked card must prevent further transmission. The most recently confirmed connection can be selected among valid mappings; this is a documented selection rule, not proof that this particular browser caused the in-store purchase.

A purchase as JSON

The URL contains measurement_id and the confidential api_secret as parameters. The following body illustrates a matched purchase. Client ID, receipt number, and timestamp are placeholders. The request-level timestamp is a number in microseconds and must represent the actual purchase time.

JSON
{
  "client_id": "1899482740.1757000000",
  "timestamp_micros": 1792301400000000,
  "events": [
    {
      "name": "purchase",
      "params": {
        "transaction_id": "HH-ALT-2026-1016-0007",
        "value": 48.9,
        "currency": "EUR",
        "items": [
          {
            "item_id": "SKU-3391",
            "affiliation": "Hamburg-Altona",
            "price": 48.9,
            "quantity": 1
          }
        ]
      }
    }
  ]
}

affiliation belongs inside the item because Google defines that field at item scope. value equals the sum of price multiplied by quantity, excluding separately reported tax and shipping. Prices are JSON numbers, not locally formatted strings such as “48,90”. The receipt identifier must distinguish purchases across stores, for example with a store prefix and receipt number, and remain unchanged on retries.

The example invents neither a session nor engagement time. A till transaction does not automatically have a browser session identifier or measured online engagement duration. Without these fields, session, engagement, and Realtime reporting are limited. An optional User-ID does not automatically turn it into a purchase attributed to an advertising session.

Time and attribution have different limits

Google permits backdating by up to 72 hours. For older timestamps, RELAXED adjusts the time to 72 hours before receipt, whereas ENFORCE_RECOMMENDATIONS rejects the event. A delayed weekly upload therefore does not necessarily lose half its receipts, but can assign them to incorrect days. The percentage of late receipts also depends on the actual timing of sales.

Online session attribution has additional requirements. The use-case documentation requires the actual session identifier, receipt no later than 24 hours after session start, and an event time within the session. A later in-store purchase often fails those conditions. The general sending guide also specifies 48 hours for processing alongside tag or SDK events. A valid client ID alone consequently does not guarantee advertising attribution.

Frequent transmission reduces delay and leaves room for recovery. A monitored queue stores purchase time, transmission attempts, and checking status. Future or questionable timestamps are investigated instead of silently changed to “now”. A local time needs the correct time zone, especially during daylight-saving transitions.

Validation and retries

A request can contain at most 25 events and must remain below 130 kB. Request-level identifiers apply to all included events; different customers do not belong in the same request. This simple implementation creates one request per receipt with that receipt’s timestamp, avoiding the conflicting documentation on individual event timestamp placement.

The validation endpoint /debug/mp/collect examines payloads with ENFORCE_RECOMMENDATIONS. Test events do not appear in reports, and the stream identifier and secret are not checked there. validation_behavior is also a documented field on the production endpoint. Google recommends omitting it there to reduce rejected events.

A 2xx response proves receipt only. Production operation therefore needs later reconciliation between intended receipts and GA4 data. Before every transmission, sender code checks mapping, current permission, amount, currency, items, and receipt identifier. A connection interruption leaves uncertainty about whether the first request arrived. A retry retains the receipt identifier and uses controlled retry logic; one HTTP code is not complete delivery evidence.

Refunds and older data

A refund is a new event. When an item purchased a week ago is refunded today, a refund can carry today’s refund time and the original transaction_id. Its timestamp does not need resetting to the purchase date. Item-level reporting requires the affected items and quantities. A refund that actually occurred a week ago but was discovered only today is instead a delayed event.

For historical analysis, till data can be joined with available GA4 exports in BigQuery. This creates a separate analysis and does not retroactively insert missing events into the GA4 interface. It depends on available exports, consistent identifiers, retention, and permitted processing; unlimited history is not guaranteed.

Questions and answers

What happens without an online mapping?

This implementation sends no invented replacement client to GA4. The receipt remains in the business’s till analysis. A separately designed offline measurement system needs its own goals and checks; a formally valid random identifier does not establish a connection to an online visit.

What changes during key rotation?

The internal HMAC lookup values change. Versioned keys and controlled migration of lawfully available card data allow new mappings. The separate Analytics User-ID can remain unchanged. Old keys and table copies need a defined deletion process; a compromised key cannot remain in use indefinitely.

Why not plain SHA-256 without a key?

A fixed prefix and eight digits provide only 100 million possible numbers. Without a secret key, a precomputed list can compare these values against stored hashes. HMAC makes that attack harder with a sufficiently strong, protected key. The mapping still relates to a person while a connection to an account remains possible.

Lukas Wojcik

Lukas Wojcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Get in touch

A question or an idea for a web project? Tell me a little about it. This form uses reCAPTCHA to protect against spam.

Write a comment

Differing figures from other accounts and questions about the setup are welcome here.

The email address is not published. Required fields are marked with an asterisk.