LW IT Solutions logo LW IT Solutions logo LW IT Solutions
« Blog-Übersicht /Digital Analytics/Tutorials / Ladenkäufe per Measurement Protocol nach GA4 hochladen:...
Diesen Artikel in anderen Sprachen lesen:

Ladenkäufe per Measurement Protocol nach GA4 hochladen: Die Kundenkartennummer als Schlüssel

Ladenkäufe per Measurement Protocol nach GA4 hochladen: Die Kundenkartennummer als Schlüssel
Inhalt
  1. Was überbrückt werden muss
  2. Die Kartennummer wird gehasht, bevor sie irgendwohin geht
  3. Die Paarung festhalten, solange die Kundin oder der Kunde online ist
  4. Die Zuordnungstabelle
  5. Eine client_id für alle, die nie online waren
  6. Die Nutzlast, Feld für Feld
  7. Der Zeitstempel und ein Fenster, das nur an einem Ende offen ist
  8. Eine Anfrage je Kunde, und warum Bündelung hier anders funktioniert
  9. Prüfen vor dem Senden, weil der Produktivbetrieb nie antwortet
  10. Was die Prüfung nicht bemerkt
  11. Der Upload-Rhythmus, und was ein Wochenlauf kostet
  12. Was das nicht löst
  13. Fragen und Antworten
  14. Quellen

Eine Ladenkasse weiß drei Dinge über einen Verkauf: was gekauft wurde, was es gekostet hat und welche Kundenkarte am Tresen über den Scanner ging. GA4 weiß nichts davon. GA4 kennt Browser, und es kennt sie über einen Cookie-Wert, den es im Laden nie gegeben hat.

Das Measurement Protocol ist der einzige Weg, der das Wissen der Kasse in dieselbe Property tragen kann wie die Daten der Website. Es ist ein gewöhnlicher HTTPS-POST, es braucht keine Bibliothek, und es nimmt fast alles an — genau deshalb muss dieser Weg sorgfältig gebaut werden statt schnell.

Die Kette von der Kundenkartennummer über einen Hash und eine Zuordnungstabelle bis zur Measurement-Protocol-Nutzlast, darunter eine Zeitachse mit dem angenommenen Zeitfenster, seinen gemessenen Grenzen und dem Anteil verlorener Belege bei drei Upload-Rhythmen
Die Kette von der Kartennummer zur Nutzlast, und das Fenster, in das der Zeitstempel fallen muss.

Was überbrückt werden muss

Zwei Felder einer Measurement-Protocol-Nutzlast tragen Identität, und sie sind nicht austauschbar.

client_id ist die Geräteidentität. Diesen Wert schreibt das GA4-Tag im Browser in das _ga-Cookie, und er ist Pflicht — eine Nutzlast ohne ihn wies der Prüfserver mit VALUE_REQUIRED ab, selbst wenn eine user_id dabei war. user_id ist die Personenidentität, freiwillig, und das Feld, das einen Gerätewechsel übersteht.

Die Kundenkartennummer ist weder das eine noch das andere. Diese Nummer ist eine dritte Kennung, die nur im Laden existiert, und die ganze Arbeit besteht darin, aus ihr die beiden anderen zu machen.

Die Kartennummer wird gehasht, bevor sie irgendwohin geht

Eine Kartennummer bezeichnet eine Person und gehört in die Kundenbeziehung. Im Klartext an eine Analytics-Property geschickt macht sie aus einer Ladenkennung einen dauerhaften Eintrag auf fremder Infrastruktur, und GA4 verbietet das Senden personenbeziehbarer Daten ausdrücklich.

Ein Hash mit Schlüssel löst beides zugleich. Der Pfeffer ist ein Geheimnis, das auf dem eigenen Server bleibt; ohne ihn lässt sich der Hash aus einer gestohlenen Liste von Kartennummern nicht nachrechnen.

PHP
function card_key(string $card, string $pepper): string {
    // Zuerst vereinheitlichen: die Kasse schreibt "LW 4471 9930",
    // das Webformular "lw-4471-9930". Ohne diesen Schritt hashen
    // beide Seiten verschieden, und nichts findet je zusammen.
    $clean = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $card));
    return hash_hmac('sha256', $clean, $pepper);
}

Diese Vereinheitlichung ist die Stelle, an der Integrationen still kaputtgehen. Zwei Systeme, die dieselbe Karte verschieden schreiben, erzeugen zwei verschiedene Hashes, und die Zusammenführung scheitert danach ohne Fehlermeldung an irgendeiner Stelle.

Die Paarung festhalten, solange die Kundin oder der Kunde online ist

Die Brücke entsteht einmal, im Browser, in dem einzigen Moment, in dem beide Kennungen am selben Ort vorliegen: wenn eine Kartennummer im Kundenkonto eingetragen oder beim Online-Kauf verknüpft wird.

Die client_id ist ein Teil des Werts im _ga-Cookie. Der folgende Code liest sie aus, indem er die letzten beiden Abschnitte mit einem Punkt verbindet.

PHP
// _ga sieht aus wie GA1.1.1899482740.1757000000
// Die client_id sind die letzten beiden Abschnitte, mit Punkt verbunden.
function ga_client_id(): ?string {
    if (empty($_COOKIE['_ga'])) { return null; }
    $parts = explode('.', $_COOKIE['_ga']);
    if (count($parts) < 4) { return null; }
    return $parts[count($parts) - 2] . '.' . $parts[count($parts) - 1];
}

Im selben Moment setzt die Website denselben Hash als user_id auf dem Web-Stream. Rechnen Website und Uploader hier verschiedene Werte aus, bekommt GA4 zwei einander fremde Personen, und keine der beiden Seiten meldet einen Fehler.

Die Zuordnungstabelle

Eine Karte gehört zu einer Person, doch diese Person kann mehrere Browser nutzen. Die Tabelle muss deshalb mehrere Zuordnungen je Karte speichern können. Der Uploader braucht außerdem eine Regel, nach der er eine Zeile auswählt.

SQL
CREATE TABLE card_pairing (
  card_hash  CHAR(64)    NOT NULL,
  client_id  VARCHAR(64) NOT NULL,
  first_seen DATETIME    NOT NULL,
  last_seen  DATETIME    NOT NULL,
  PRIMARY KEY (card_hash, client_id),
  KEY by_time (card_hash, last_seen)
) ENGINE=InnoDB;

Am besten trägt die zuletzt gesehene Paarung: ORDER BY last_seen DESC LIMIT 1. Ein Telefon aus der vergangenen Woche ist die bessere Schätzung als ein Schreibtischrechner, der einmal im Jahr benutzt wird; die älteste Zeile hängt den Kauf an ein Gerät, das längst ersetzt sein kann.

Eine client_id für alle, die nie online waren

Im Laden hat die Mehrzahl der Karten überhaupt keine Paarung. Auch diese Belege müssen irgendwohin, und eine client_id zu erfinden ist erlaubt — aber nicht in beliebiger Form.

Der Prüfserver erzwingt ein Format. Am 9. September 2026 mit gesetztem ENFORCE_RECOMMENDATIONS gemessen: card-LW-4471-9930, ein zwanzig Zeichen langes Hash-Präfix und die einzelne Ziffer 7 wurden allesamt abgewiesen, mit client_id [...] has invalid format. It should be in <number>.<number> format. Ein Komma statt des Punktes wurde abgewiesen, ein dritter Abschnitt ebenso. Angenommen wurden: 1.1, 0.0, zwanzigstellige Zahlen auf beiden Seiten und — unerwartet — 1899482740. und .1757000000, jeweils mit fehlender Hälfte.

Eine feste Ableitung aus dem Kartenhash erfüllt das Format und bleibt über alle Läufe hinweg gleich.

PHP
// Zwei 32-Bit-Zahlen aus den ersten 16 Hex-Zeichen.
$h   = card_key($card, $pepper);
$cid = hexdec(substr($h, 0, 8)) . '.' . hexdec(substr($h, 8, 8));
// LW-4471-9930 -> 2531008582.2372831494, vom Pruefserver angenommen

Was das bringt und was nicht: Die Belege einer Karte landen jetzt auf einer festen Identität, statt sich zu verstreuen. Mit den Web-Sitzungen derselben Person wachsen sie nicht zusammen, denn der abgeleitete Wert passt zu keinem _ga-Cookie auf der Welt. Diese Verbindung trägt allein die user_id, und auch die nur in einer Property, deren Berichtsidentität sie benutzen darf.

Die Nutzlast, Feld für Feld

Zwei Zugangsdaten in der Abfragezeichenfolge, alles andere im JSON-Rumpf. measurement_id und api_secret gehören nie in den Rumpf — dort werden sie zu unbekannten Feldern, und ein unbekanntes Feld wird nicht übergangen, sondern verwirft die ganze Nutzlast.

HTTP
POST /mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=SECRET HTTP/1.1
Host: www.google-analytics.com
Content-Type: application/json

{
  "client_id": "2531008582.2372831494",
  "user_id": "9f2c4b1e8a7d3c5f0b6e2a9d4c8f1b3e7a0d5c2f9b4e8a1d6c3f0b7e2a5d9c4f",
  "consent": { "ad_user_data": "GRANTED", "ad_personalization": "DENIED" },
  "events": [
    {
      "name": "purchase",
      "timestamp_micros": "1792301400000000",
      "params": {
        "transaction_id": "HH-ALT-2026-1016-0007",
        "value": 48.90,
        "currency": "EUR",
        "affiliation": "Filiale Hamburg-Altona",
        "session_id": "1792301400",
        "engagement_time_msec": "1",
        "items": [
          { "item_id": "SKU-3391", "item_name": "Handkaffeemuehle",
            "price": 48.90, "quantity": 1 }
        ]
      }
    }
  ]
}

transaction_id ist die Belegnummer und das Einzige, was einen wiederholten Upload davon abhält, einen Verkauf doppelt zu zählen. affiliation trägt die Filiale — das Feld, das die Daten überhaupt erst wertvoll macht. session_id und engagement_time_msec hängen das Ereignis in den Berichten an eine Sitzung; das Protokoll verlangt sie nicht, und ihr Fehlen wird nirgends gemeldet.

Zwei Typregeln, beide gemessen. Ein Item-price von "48.90" als Text wird angenommen, "48,90" mit Known item param [price] should have type [int64] or [double] abgewiesen, ebenso "abc" und eine leere Zeichenkette. Der value auf Ereignisebene wird nicht ebenso geprüft: dort ging "abc" kommentarlos durch. Ein deutsch formatierter Betrag aus der Kasse scheitert also an der einen Stelle und rutscht an der anderen still hindurch.

Der Zeitstempel und ein Fenster, das nur an einem Ende offen ist

Dieses Feld entscheidet, ob ein Offline-Upload überhaupt funktioniert, und beide Kanten wurden gemessen statt aus der Doku übernommen.

Abstand zu jetztUrteil des Prüfservers
71 h 59 min 55 s in der Vergangenheitangenommen
72 h 00 min 00 s in der Vergangenheitabgewiesen, VALUE_INVALID
0 s — die laufende Sekundeangenommen
5 s in der Zukunftabgewiesen, VALUE_INVALID
15 min in der Zukunftabgewiesen

Die hintere Kante ist exakt und schließt aus: 72 Stunden auf die Sekunde genau sind bereits zu alt. Die vordere Kante ist die Überraschung. Der Meldungstext lautet Measurement protocol only supports timestamps [15m] into the future, abgewiesen wurde aber jeder geprüfte Wert nach vorn, bis hinunter zu fünf Sekunden. Die Uhr der sendenden Maschine lag im selben Lauf innerhalb von 0,8 Sekunden am Date-Kopf von Google — an der Messseite liegt es also nicht.

Für einen Laden heißt das: die Kassenuhr ist ein laufendes Risiko. Ein Terminal, das eine Minute vorgeht, erzeugt Belege, die das Protokoll als Zukunftsereignisse behandelt, und der Produktivendpunkt nimmt sie trotzdem mit 204 entgegen. Ein Uploader sollte die Belegzeit gegen die eigene, verlässliche Uhr halten und alles Vordatierte zurückhalten, statt es zu senden.

Der Wert muss in Mikrosekunden vorliegen. Ein Zeitstempel in Sekunden wird als Datum im Jahr 1970 gelesen und mit too far in the past beantwortet. Die Meldung kann einen Rückdatierungsfehler vermuten lassen, obwohl tatsächlich die Zeiteinheit falsch ist.

Eine Anfrage je Kunde, und warum Bündelung hier anders funktioniert

client_id und user_id stehen auf der obersten Ebene der Nutzlast, neben events, nicht innerhalb eines Ereignisses. Eine client_id in ein Ereignis zu verschieben wurde rundheraus abgewiesen: die Nutzlast ließ sich nicht mehr lesen, mit invalid JSON in google.analytics.measurement.Measurement @ events[1]. Bei einer user_id im Ereignis geschah dasselbe.

Eine Anfrage kann also keine Kunden mischen. Fünfundzwanzig Belege von fünfundzwanzig Karteninhabern in einen Aufruf zu packen geht nicht, und die Arbeitseinheit des Uploaders ist der Kunde, nicht das Bündel.

Was dagegen geht, ist ein Zeitstempel je Ereignis: timestamp_micros innerhalb eines Ereignisses wurde angenommen, auch in einer Nutzlast mit einem obersten Zeitstempel von vor einer Stunde und einem zweiten Ereignis von vor zwei Stunden. Eine Anfrage kann damit sämtliche Belege einer Karte aus einem ganzen Zeitraum tragen, jeden mit seiner eigenen Zeit.

Die dokumentierte Obergrenze liegt bei fünfundzwanzig Ereignissen und 130 kB je Anfrage. Der Prüfserver erzwingt keine davon — sechsundzwanzig Ereignisse gingen durch, und hundert Belege mit je zwei Positionen kamen auf 28,8 kB und ebenfalls durch —, aber eine dokumentierte und unerzwungene Grenze bleibt eine Grenze, und die Folgen zeigen sich in den Berichten.

Prüfen vor dem Senden, weil der Produktivbetrieb nie antwortet

Der Produktivendpunkt gibt auf alles 204 No Content zurück. Mit den Nutzlasten aus diesem Artikel gemessen: ein gültiger Beleg, ein Beleg mit Datum vor dreißig Tagen, ein Zeitstempel in Sekunden statt Mikrosekunden, ein vierbuchstabiger Währungscode und eine Nutzlast ganz ohne client_id — alle 204, alle unter 220 Millisekunden, alle mit leerem Rumpf. Der Fall ohne client_id ist der aufschlussreiche, denn genau diese Nutzlast weist der Prüfserver ab.

Der Prüfendpunkt ist dieselbe Adresse mit /debug vor dem Pfad. Er kontrolliert weder measurement_id noch api_secret, eine Nutzlast lässt sich also prüfen, ohne dass ein Geheimnis das Haus verlässt.

Shell
curl -s -X POST \
  'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=platzhalter' \
  -H 'Content-Type: application/json' \
  -d '{"client_id":"2531008582.2372831494",
       "validation_behavior":"ENFORCE_RECOMMENDATIONS",
       "events":[{"name":"purchase","params":{"currency":"EUR","value":48.90}}]}'

validation_behavior gehört in die Prüfung und sonst nirgendwohin. An den Produktivendpunkt geschickt ist es ein unbekanntes Feld, und ein unbekanntes Feld verwirft genau die Nutzlast, die es absichern sollte.

Eine Eigenschaft des Prüfservers prägt den Umgang mit ihm: er meldet nur den ersten Fund. Drei Probleme in einem Ereignis ergeben eine Meldung, und die zweite erscheint erst, wenn die erste behoben ist. Eine frisch gebaute Nutzlast braucht deshalb mehrere Runden — genau dafür gibt es einen Prüfer, der alle Fehler auf einmal auflistet.

Was die Prüfung nicht bemerkt

Mit gesetztem ENFORCE_RECOMMENDATIONS ging all das ohne eine einzige Meldung durch: ein purchase ohne transaction_id, ohne value, ohne currency, ohne items, mit leerem items-Feld und ganz ohne Parameter. Zwei Ereignisse mit derselben transaction_id in einer Anfrage gingen durch. Eine user_id mit 257 Zeichen ging durch, ein Zeichen über dem dokumentierten Höchstwert. Ein Währungscode EURO ging durch, kleingeschriebenes eur ebenfalls.

Nichts davon ist ein Fehler im Prüfer; es ist die Grenze dessen, was eine Protokollprüfung wissen kann. Ob ein Beleg eine Nummer hat, ob ein Betrag ein echter Betrag ist und ob derselbe Verkauf bereits hochgeladen wurde, sind Fragen an die Daten des Ladens, und sie müssen beantwortet sein, bevor die Nutzlast gebaut wird.

Der Upload-Rhythmus, und was ein Wochenlauf kostet

Das 72-Stunden-Fenster macht aus dem Zeitplan eine Entwurfsentscheidung. Für einen Uploader, der um 03:00 Uhr läuft und ganze Tage abdeckt:

RhythmusÄltester Beleg beim UploadAnteil außerhalb des Fensters
jede Nacht27 h0 %
alle drei Tage75 h4,2 %
wöchentlich171 h58,9 %

Ein Wochenlauf verliert mehr als die Hälfte seiner Fracht, und er verliert sie lautlos: der Endpunkt antwortet den abgewiesenen Belegen mit genau demselben 204 wie den angenommenen. Ein Nachtlauf hat 45 Stunden Luft, und diese Luft macht eine ausgefallene Nacht überhaupt erst wiederholbar — ein versäumter Lauf kann am Folgetag nachgeholt werden, und alles kommt trotzdem an.

Dieser Zeitpuffer spricht auch dafür, fehlgeschlagene Uploads in einer Warteschlange aufzubewahren. Jeder noch nicht gesendete Beleg behält seine eigene Frist. Hält die Warteschlange die Belegzeit fest, kann der Uploader erkennen, welche Belege noch im Upload-Fenster liegen und welche bereits zu alt sind.

Was das nicht löst

Drei Grenzen gehören klar benannt.

Die Messungen oben beschreiben, was das Protokoll annimmt. Ob ein zusammengeführter Kauf danach in einem bestimmten Bericht auftaucht, hängt an der Berichtsidentität der Property und daran, ob auf der Website überhaupt eine user_id erhoben wird — eine Frage der GA4-Einstellungen, keine des Protokolls, und nichts davon wurde hier gemessen.

Rückgaben und Stornos haben auf diesem Weg keinen bequemen Platz. Ein refund-Ereignis gibt es, aber eine Rückgabe, die vier Tage nach dem Verkauf eintrifft, fällt genauso aus dem Fenster wie ein verspäteter Beleg.

Was das Fenster ausschließt, lässt ein anderer Weg weiterhin zu. Belege, die älter als drei Tage sind, Rückgaben, die eine Woche später eintreffen, und jedes Nacharbeiten von Vergangenheit gehören gar nicht in dieses Protokoll: Bei einer Property mit eingeschaltetem BigQuery-Export gehören sie in einen Join gegen die rohen events_*-Tabellen, über dieselben Kennungen, die dieser Artikel aufbaut. Dieser Weg kennt keine Zeitgrenze. Er ist auch der Grund, warum die 72-Stunden-Kante eine Frage des Zeitplans bleibt und kein dauerhafter Verlust — solange die Belege selbst aufgehoben werden.

Und das Protokoll selbst ist fertig. Google hat das Measurement Protocol im Juni 2026 in einen endgültigen Zustand versetzt — keine Abschaltung, keine neuen Funktionen — und verweist neue Server-zu-Server-Anbindungen auf die Data Manager API. Für einen Laden, dessen Kasse bereits Belege exportiert, ist das kein Notfall: die hier gemessenen Grenzen sind die Grenzen, die bleiben.

Fragen und Antworten

Was geschieht, wenn der Pfeffer gewechselt werden muss, etwa nach einem Leck?

Jeder Hash ändert sich, und damit reißt die Personengeschichte in GA4 an diesem Tag ab. Betroffen sind drei Stellen:

  1. Die user_id. Website und Uploader rechnen mit demselben Pfeffer. Wechselt nur eine Seite, entstehen genau die zwei einander fremden Personen, vor denen der Artikel warnt. Beide müssen deshalb im selben Moment umgestellt werden.
  2. Die Zuordnungstabelle. Ihre Schlüssel sind alte Hashes, und ein HMAC lässt sich nicht zurückrechnen. Neu aufbauen lässt sich die Tabelle nur, indem jede Kartennummer aus dem Kundenstamm mit altem und neuem Pfeffer gehasht und der alte Schlüssel durch den neuen ersetzt wird.
  3. Die abgeleiteten client_ids. Auch sie stammen aus dem Hash. Jede Karte ohne Paarung erscheint ab dem Wechsel als neue Identität, und die Belege davor und danach wachsen nicht zusammen.

Ein Pfefferwechsel ist damit eher eine Datenmigration als eine gewöhnliche Wartungsaufgabe. Der Pfeffer liegt überall dort, wo der Hash berechnet wird, also bei der Website und beim Uploader. Beide Stellen müssen ihn gleich gut schützen.

Warum ein Hash mit Schlüssel und nicht einfach SHA-256 der Kartennummer?

Weil es zu wenige Kartennummern gibt. Bei einem angenommenen Format aus festem Kürzel und acht Ziffern, wie im Beispiel LW 4471 9930, sind nur 100 Millionen Nummern möglich; sie alle ohne Schlüssel zu hashen und jeden Hash seiner Nummer zuzuordnen, dauert auf gewöhnlicher Hardware nicht lange. Der Pfeffer macht diese Rechnung unmöglich, solange er geheim bleibt.

Woran erkennt ein Uploader einen gescheiterten Upload, wenn der Produktivendpunkt immer mit 204 antwortet?

Nur an der Verbindung selbst: Kommt keine Antwort, bricht die Verbindung ab oder erscheint ein anderer Status als 204, gehört der Beleg in die Warteschlange. Alles, was den Inhalt betrifft, muss vor dem Senden entschieden sein, weil der Endpunkt es danach nicht meldet. Vier Prüfungen gehören deshalb in den Uploader selbst:

  • Die Belegzeit gegen die eigene Uhr: Was älter als 72 Stunden ist, gehört nicht mehr in dieses Protokoll, sondern auf den Weg über BigQuery; Vordatiertes bleibt zurück.
  • Die Formate: Preise als Zahl mit Dezimalpunkt, die client_id als <Zahl>.<Zahl>, der Zeitstempel in Mikrosekunden.
  • Die Angaben, deren Fehlen der Prüfserver durchgehen lässt: transaction_id, value, currency und items.
  • Jede neue Form der Nutzlast gegen den Prüfendpunkt, und zwar so oft, bis keine Meldung mehr kommt, weil er nur den ersten Fund nennt.

Ob die Belege danach wirklich angekommen sind, lässt sich nur in den Daten der Property selbst nachsehen, bei eingeschaltetem BigQuery-Export etwa über die Belegnummern.

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.

Kontakt

Eine Frage oder eine Idee für ein Webprojekt? Schreib mir kurz, worum es geht. Das Formular nutzt reCAPTCHA zum Schutz vor Spam.

Kommentar schreiben

Abweichende Zahlen aus anderen Konten und Rückfragen zur Einrichtung sind hier willkommen.

Die E-Mail-Adresse wird nicht veröffentlicht. Pflichtfelder sind mit einem Stern versehen.