{"id":17806,"date":"2026-10-19T07:50:00","date_gmt":"2026-10-19T05:50:00","guid":{"rendered":"https:\/\/www.lukaswojcik.com\/blog\/?p=17806"},"modified":"2026-10-09T22:39:06","modified_gmt":"2026-10-09T20:39:06","slug":"ga4-measurement-protocol-offline-purchases-loyalty-card","status":"publish","type":"post","link":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/","title":{"rendered":"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key"},"content":{"rendered":"<p>A shop till knows three things about a sale: what was bought, what it cost, and which loyalty card was held up at the counter. GA4 knows none of them. It knows browsers, and it knows them by a cookie value that never existed in the shop.<\/p>\n\n<p>The Measurement Protocol is the one path that can carry the till&#8217;s knowledge into the same property as the website data. It is an ordinary HTTPS POST, it needs no library, and it will accept almost anything \u2014 which is the reason this has to be built carefully rather than quickly.<\/p>\n\n<figure class=\"lw-diagram\">\n<img src=\"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/diagrams\/kartenschluessel-en.png\" width=\"1120\" height=\"580\" decoding=\"async\" loading=\"lazy\"\n     alt=\"The chain from a loyalty card number through a hash and a lookup table to a Measurement Protocol payload, above a time axis showing the accepted upload window with its measured boundaries and the share of receipts lost at three upload rhythms\">\n<figcaption>The chain from card number to payload, and the window the timestamp has to fall into.<\/figcaption>\n<\/figure>\n\n<h2>What has to be bridged<\/h2>\n\n<p>Two fields in a Measurement Protocol payload carry identity, and they are not interchangeable.<\/p>\n\n<p><code>client_id<\/code> is the device identity. It is what the GA4 tag writes into the <code>_ga<\/code> cookie in a browser, and it is <strong>required<\/strong> \u2014 a payload without it was refused by the validation server with <code>VALUE_REQUIRED<\/code>, even when a <code>user_id<\/code> was present. <code>user_id<\/code> is the person identity, optional, and the field that survives a new device.<\/p>\n\n<p>The loyalty card number is neither. It is a third identifier that lives only in the shop, and the whole exercise consists of turning it into the other two.<\/p>\n\n<h2>The card number is hashed before it goes anywhere<\/h2>\n\n<p>A card number identifies a person and belongs to a customer relationship. Sending it to an analytics property in clear text turns a shop identifier into a permanent record on someone else&#8217;s infrastructure, and GA4 forbids sending personally identifying data outright.<\/p>\n\n<p>A keyed hash solves both problems at once. The pepper is a secret that stays on the server; without it the hash cannot be recomputed from a stolen list of card numbers.<\/p>\n\n\n\n<figure style=\"--cbb-digits:1\" class=\"cbb cbb--wrap cbb--numbers wp-block-code-beautifier-block-code\" data-lang=\"php\"><figcaption class=\"cbb-head\"><span class=\"cbb-lang\">PHP<\/span><span class=\"cbb-actions\"><button type=\"button\" class=\"cbb-button cbb-button--wrap\" data-cbb-action=\"wrap\" aria-pressed=\"true\" title=\"Wrap long lines\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path d=\"M2 4h12M2 8h9.5a2.5 2.5 0 0 1 0 5H8m0 0 1.5-1.5M8 13l1.5 1.5M2 12h3\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\/><\/svg><span class=\"cbb-sr\">Wrap long lines<\/span><\/button><button type=\"button\" class=\"cbb-button cbb-button--copy\" data-cbb-action=\"copy\" data-cbb-done=\"Copied\" data-cbb-failed=\"Press Ctrl+C to copy\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8.5\" rx=\"1.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><path d=\"M10.5 3.5V3A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v6.5A1.5 1.5 0 0 0 4 11h.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><\/svg><span class=\"cbb-button-label\" aria-live=\"polite\">Copy<\/span><\/button><\/span><\/figcaption><pre class=\"cbb-pre\" tabindex=\"0\"><code class=\"cbb-code language-php\" style=\"counter-reset:cbb-line 0;\"><span class=\"cbb-line\"><span class=\"cbb-keyword\">function<\/span> <span class=\"cbb-function\">card_key<\/span>(string <span class=\"cbb-variable\">$card<\/span>, string <span class=\"cbb-variable\">$pepper<\/span>): string {\n<\/span><span class=\"cbb-line\">    <span class=\"cbb-comment\">\/\/ Normalise first: the till writes \"LW 4471 9930\", the web form<\/span>\n<\/span><span class=\"cbb-line\">    <span class=\"cbb-comment\">\/\/ \"lw-4471-9930\". Without this the two sides hash differently<\/span>\n<\/span><span class=\"cbb-line\">    <span class=\"cbb-comment\">\/\/ and nothing ever joins.<\/span>\n<\/span><span class=\"cbb-line\">    <span class=\"cbb-variable\">$clean<\/span> = <span class=\"cbb-function\">strtoupper<\/span>(<span class=\"cbb-function\">preg_replace<\/span>(<span class=\"cbb-string\">'\/&#91;^A-Za-z0-9&#93;\/'<\/span>, <span class=\"cbb-string\">''<\/span>, <span class=\"cbb-variable\">$card<\/span>));\n<\/span><span class=\"cbb-line\">    <span class=\"cbb-keyword\">return<\/span> <span class=\"cbb-function\">hash_hmac<\/span>(<span class=\"cbb-string\">'sha256'<\/span>, <span class=\"cbb-variable\">$clean<\/span>, <span class=\"cbb-variable\">$pepper<\/span>);\n<\/span><span class=\"cbb-line\">}<\/span><\/code><\/pre><\/figure>\n\n\n\n<p>The normalisation is the part that quietly breaks integrations. Two systems that write the same card differently produce two different hashes, and the join then fails without an error anywhere.<\/p>\n\n<h2>Recording the pairing while the customer is online<\/h2>\n\n<p>The bridge is built once, in the browser, at the only moment when both identifiers exist in the same place: when a card number is entered on an account page or linked during an online checkout.<\/p>\n\n<p>The <code>client_id<\/code> is stored within the <code>_ga<\/code> cookie value. To extract it, the code below joins the last two segments with a dot.<\/p>\n\n\n\n<figure style=\"--cbb-digits:1\" class=\"cbb cbb--wrap cbb--numbers wp-block-code-beautifier-block-code\" data-lang=\"php\"><figcaption class=\"cbb-head\"><span class=\"cbb-lang\">PHP<\/span><span class=\"cbb-actions\"><button type=\"button\" class=\"cbb-button cbb-button--wrap\" data-cbb-action=\"wrap\" aria-pressed=\"true\" title=\"Wrap long lines\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path d=\"M2 4h12M2 8h9.5a2.5 2.5 0 0 1 0 5H8m0 0 1.5-1.5M8 13l1.5 1.5M2 12h3\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\/><\/svg><span class=\"cbb-sr\">Wrap long lines<\/span><\/button><button type=\"button\" class=\"cbb-button cbb-button--copy\" data-cbb-action=\"copy\" data-cbb-done=\"Copied\" data-cbb-failed=\"Press Ctrl+C to copy\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8.5\" rx=\"1.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><path d=\"M10.5 3.5V3A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v6.5A1.5 1.5 0 0 0 4 11h.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><\/svg><span class=\"cbb-button-label\" aria-live=\"polite\">Copy<\/span><\/button><\/span><\/figcaption><pre class=\"cbb-pre\" tabindex=\"0\"><code class=\"cbb-code language-php\" style=\"counter-reset:cbb-line 0;\"><span class=\"cbb-line\"><span class=\"cbb-comment\">\/\/ _ga looks like GA1.1.1899482740.1757000000<\/span>\n<\/span><span class=\"cbb-line\"><span class=\"cbb-comment\">\/\/ The client_id is the last two segments joined by a dot.<\/span>\n<\/span><span class=\"cbb-line\"><span class=\"cbb-keyword\">function<\/span> <span class=\"cbb-function\">ga_client_id<\/span>(): ?string {\n<\/span><span class=\"cbb-line\">    <span class=\"cbb-keyword\">if<\/span> (<span class=\"cbb-keyword\">empty<\/span>(<span class=\"cbb-variable\">$_COOKIE<\/span>&#91;<span class=\"cbb-string\">'_ga'<\/span>&#93;)) { <span class=\"cbb-keyword\">return<\/span> <span class=\"cbb-constant\">null<\/span>; }\n<\/span><span class=\"cbb-line\">    <span class=\"cbb-variable\">$parts<\/span> = <span class=\"cbb-function\">explode<\/span>(<span class=\"cbb-string\">'.'<\/span>, <span class=\"cbb-variable\">$_COOKIE<\/span>&#91;<span class=\"cbb-string\">'_ga'<\/span>&#93;);\n<\/span><span class=\"cbb-line\">    <span class=\"cbb-keyword\">if<\/span> (<span class=\"cbb-function\">count<\/span>(<span class=\"cbb-variable\">$parts<\/span>) &lt; <span class=\"cbb-number\">4<\/span>) { <span class=\"cbb-keyword\">return<\/span> <span class=\"cbb-constant\">null<\/span>; }\n<\/span><span class=\"cbb-line\">    <span class=\"cbb-keyword\">return<\/span> <span class=\"cbb-variable\">$parts<\/span>&#91;<span class=\"cbb-function\">count<\/span>(<span class=\"cbb-variable\">$parts<\/span>) - <span class=\"cbb-number\">2<\/span>&#93; . <span class=\"cbb-string\">'.'<\/span> . <span class=\"cbb-variable\">$parts<\/span>&#91;<span class=\"cbb-function\">count<\/span>(<span class=\"cbb-variable\">$parts<\/span>) - <span class=\"cbb-number\">1<\/span>&#93;;\n<\/span><span class=\"cbb-line\">}<\/span><\/code><\/pre><\/figure>\n\n\n\n<p>At the same moment the site sets the <strong>same hash<\/strong> as <code>user_id<\/code> on the web stream. If the website and the uploader compute different values here, GA4 receives two unrelated people and no error is raised on either side.<\/p>\n\n<h2>The mapping table<\/h2>\n\n<p>One card belongs to one person, but that person may use several browsers. The table therefore needs to store more than one pairing per card, and the uploader needs a rule for choosing a row.<\/p>\n\n\n\n<figure style=\"--cbb-digits:1\" class=\"cbb cbb--wrap cbb--numbers wp-block-code-beautifier-block-code\" data-lang=\"sql\"><figcaption class=\"cbb-head\"><span class=\"cbb-lang\">SQL<\/span><span class=\"cbb-actions\"><button type=\"button\" class=\"cbb-button cbb-button--wrap\" data-cbb-action=\"wrap\" aria-pressed=\"true\" title=\"Wrap long lines\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path d=\"M2 4h12M2 8h9.5a2.5 2.5 0 0 1 0 5H8m0 0 1.5-1.5M8 13l1.5 1.5M2 12h3\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\/><\/svg><span class=\"cbb-sr\">Wrap long lines<\/span><\/button><button type=\"button\" class=\"cbb-button cbb-button--copy\" data-cbb-action=\"copy\" data-cbb-done=\"Copied\" data-cbb-failed=\"Press Ctrl+C to copy\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8.5\" rx=\"1.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><path d=\"M10.5 3.5V3A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v6.5A1.5 1.5 0 0 0 4 11h.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><\/svg><span class=\"cbb-button-label\" aria-live=\"polite\">Copy<\/span><\/button><\/span><\/figcaption><pre class=\"cbb-pre\" tabindex=\"0\"><code class=\"cbb-code language-sql\" style=\"counter-reset:cbb-line 0;\"><span class=\"cbb-line\"><span class=\"cbb-keyword\">CREATE<\/span> <span class=\"cbb-keyword\">TABLE<\/span> <span class=\"cbb-function\">card_pairing<\/span> (\n<\/span><span class=\"cbb-line\">  card_hash  <span class=\"cbb-type\">CHAR<\/span>(<span class=\"cbb-number\">64<\/span>)    <span class=\"cbb-keyword\">NOT<\/span> <span class=\"cbb-constant\">NULL<\/span>,\n<\/span><span class=\"cbb-line\">  client_id  <span class=\"cbb-type\">VARCHAR<\/span>(<span class=\"cbb-number\">64<\/span>) <span class=\"cbb-keyword\">NOT<\/span> <span class=\"cbb-constant\">NULL<\/span>,\n<\/span><span class=\"cbb-line\">  first_seen <span class=\"cbb-type\">DATETIME<\/span>    <span class=\"cbb-keyword\">NOT<\/span> <span class=\"cbb-constant\">NULL<\/span>,\n<\/span><span class=\"cbb-line\">  last_seen  <span class=\"cbb-type\">DATETIME<\/span>    <span class=\"cbb-keyword\">NOT<\/span> <span class=\"cbb-constant\">NULL<\/span>,\n<\/span><span class=\"cbb-line\">  <span class=\"cbb-keyword\">PRIMARY<\/span> <span class=\"cbb-keyword\">KEY<\/span> (card_hash, client_id),\n<\/span><span class=\"cbb-line\">  <span class=\"cbb-keyword\">KEY<\/span> <span class=\"cbb-function\">by_time<\/span> (card_hash, last_seen)\n<\/span><span class=\"cbb-line\">) <span class=\"cbb-keyword\">ENGINE<\/span>=InnoDB;<\/span><\/code><\/pre><\/figure>\n\n\n\n<p>The rule that behaves best is the most recently seen pairing: <code>ORDER BY last_seen DESC LIMIT 1<\/code>. A phone used last week is a better guess than a desktop used once a year, and picking the oldest row attaches the purchase to a device the person may have replaced long ago.<\/p>\n\n<h2>A client_id for customers who were never online<\/h2>\n\n<p>In a shop most cards have no pairing at all. Those receipts still have to go somewhere, and inventing a <code>client_id<\/code> is allowed \u2014 but not in any shape.<\/p>\n\n<p>The validation server enforces a format. Measured on 9 September 2026 with <code>ENFORCE_RECOMMENDATIONS<\/code> set, <code>card-LW-4471-9930<\/code>, a twenty-character hash prefix and the single digit <code>7<\/code> were all refused with <code>client_id [...] has invalid format. It should be in &lt;number&gt;.&lt;number&gt; format.<\/code> A comma instead of the dot was refused, and so was a third segment. Accepted: <code>1.1<\/code>, <code>0.0<\/code>, twenty-digit numbers on both sides, and \u2014 unexpectedly \u2014 <code>1899482740.<\/code> and <code>.1757000000<\/code>, each with one side missing.<\/p>\n\n<p>A deterministic derivation from the card hash satisfies the format and stays stable across runs.<\/p>\n\n\n\n<figure style=\"--cbb-digits:1\" class=\"cbb cbb--wrap cbb--numbers wp-block-code-beautifier-block-code\" data-lang=\"php\"><figcaption class=\"cbb-head\"><span class=\"cbb-lang\">PHP<\/span><span class=\"cbb-actions\"><button type=\"button\" class=\"cbb-button cbb-button--wrap\" data-cbb-action=\"wrap\" aria-pressed=\"true\" title=\"Wrap long lines\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path d=\"M2 4h12M2 8h9.5a2.5 2.5 0 0 1 0 5H8m0 0 1.5-1.5M8 13l1.5 1.5M2 12h3\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\/><\/svg><span class=\"cbb-sr\">Wrap long lines<\/span><\/button><button type=\"button\" class=\"cbb-button cbb-button--copy\" data-cbb-action=\"copy\" data-cbb-done=\"Copied\" data-cbb-failed=\"Press Ctrl+C to copy\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8.5\" rx=\"1.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><path d=\"M10.5 3.5V3A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v6.5A1.5 1.5 0 0 0 4 11h.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><\/svg><span class=\"cbb-button-label\" aria-live=\"polite\">Copy<\/span><\/button><\/span><\/figcaption><pre class=\"cbb-pre\" tabindex=\"0\"><code class=\"cbb-code language-php\" style=\"counter-reset:cbb-line 0;\"><span class=\"cbb-line\"><span class=\"cbb-comment\">\/\/ Two 32-bit numbers out of the first 16 hex characters.<\/span>\n<\/span><span class=\"cbb-line\"><span class=\"cbb-variable\">$h<\/span>   = <span class=\"cbb-function\">card_key<\/span>(<span class=\"cbb-variable\">$card<\/span>, <span class=\"cbb-variable\">$pepper<\/span>);\n<\/span><span class=\"cbb-line\"><span class=\"cbb-variable\">$cid<\/span> = <span class=\"cbb-function\">hexdec<\/span>(<span class=\"cbb-function\">substr<\/span>(<span class=\"cbb-variable\">$h<\/span>, <span class=\"cbb-number\">0<\/span>, <span class=\"cbb-number\">8<\/span>)) . <span class=\"cbb-string\">'.'<\/span> . <span class=\"cbb-function\">hexdec<\/span>(<span class=\"cbb-function\">substr<\/span>(<span class=\"cbb-variable\">$h<\/span>, <span class=\"cbb-number\">8<\/span>, <span class=\"cbb-number\">8<\/span>));\n<\/span><span class=\"cbb-line\"><span class=\"cbb-comment\">\/\/ LW-4471-9930 -&gt; 2531008582.2372831494, accepted by the validation server<\/span><\/span><\/code><\/pre><\/figure>\n\n\n\n<p>What this buys and what it does not: the receipts of one card now land on one stable identity instead of scattering. They will not merge with that person&#8217;s web sessions, because the derived value matches no <code>_ga<\/code> cookie anywhere. The <code>user_id<\/code> is what carries that connection, and only in a property whose reporting identity is allowed to use it.<\/p>\n\n<h2>The payload, field by field<\/h2>\n\n<p>Two credentials in the query string, everything else in the JSON body. <code>measurement_id<\/code> and <code>api_secret<\/code> never belong in the body \u2014 placed there they become unknown fields, and an unknown field invalidates the whole payload rather than being ignored.<\/p>\n\n\n\n<figure style=\"--cbb-digits:2\" class=\"cbb cbb--wrap cbb--numbers wp-block-code-beautifier-block-code\" data-lang=\"http\"><figcaption class=\"cbb-head\"><span class=\"cbb-lang\">HTTP<\/span><span class=\"cbb-actions\"><button type=\"button\" class=\"cbb-button cbb-button--wrap\" data-cbb-action=\"wrap\" aria-pressed=\"true\" title=\"Wrap long lines\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path d=\"M2 4h12M2 8h9.5a2.5 2.5 0 0 1 0 5H8m0 0 1.5-1.5M8 13l1.5 1.5M2 12h3\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\/><\/svg><span class=\"cbb-sr\">Wrap long lines<\/span><\/button><button type=\"button\" class=\"cbb-button cbb-button--copy\" data-cbb-action=\"copy\" data-cbb-done=\"Copied\" data-cbb-failed=\"Press Ctrl+C to copy\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8.5\" rx=\"1.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><path d=\"M10.5 3.5V3A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v6.5A1.5 1.5 0 0 0 4 11h.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><\/svg><span class=\"cbb-button-label\" aria-live=\"polite\">Copy<\/span><\/button><\/span><\/figcaption><pre class=\"cbb-pre\" tabindex=\"0\"><code class=\"cbb-code language-http\" style=\"counter-reset:cbb-line 0;\"><span class=\"cbb-line\"><span class=\"cbb-keyword\">POST<\/span> \/mp\/collect?measurement_id=G-XXXXXXXXXX&amp;api_secret=SECRET <span class=\"cbb-meta\">HTTP\/1.1<\/span>\n<\/span><span class=\"cbb-line\"><span class=\"cbb-attr\">Host<\/span>: www.google-analytics.com\n<\/span><span class=\"cbb-line\"><span class=\"cbb-attr\">Content-Type<\/span>: application\/json\n<\/span><span class=\"cbb-line\">\n<\/span><span class=\"cbb-line\">{\n<\/span><span class=\"cbb-line\">  \"client_id\": \"2531008582.2372831494\",\n<\/span><span class=\"cbb-line\">  \"user_id\": \"9f2c4b1e8a7d3c5f0b6e2a9d4c8f1b3e7a0d5c2f9b4e8a1d6c3f0b7e2a5d9c4f\",\n<\/span><span class=\"cbb-line\">  \"consent\": { \"ad_user_data\": \"GRANTED\", \"ad_personalization\": \"DENIED\" },\n<\/span><span class=\"cbb-line\">  \"events\": &#91;\n<\/span><span class=\"cbb-line\">    {\n<\/span><span class=\"cbb-line\">      \"name\": \"purchase\",\n<\/span><span class=\"cbb-line\">      \"timestamp_micros\": \"1792301400000000\",\n<\/span><span class=\"cbb-line\">      \"params\": {\n<\/span><span class=\"cbb-line\">        \"transaction_id\": \"HH-ALT-2026-1016-0007\",\n<\/span><span class=\"cbb-line\">        \"value\": 48.90,\n<\/span><span class=\"cbb-line\">        \"currency\": \"EUR\",\n<\/span><span class=\"cbb-line\">        \"affiliation\": \"Store Hamburg-Altona\",\n<\/span><span class=\"cbb-line\">        \"session_id\": \"1792301400\",\n<\/span><span class=\"cbb-line\">        \"engagement_time_msec\": \"1\",\n<\/span><span class=\"cbb-line\">        \"items\": &#91;\n<\/span><span class=\"cbb-line\">          { \"item_id\": \"SKU-3391\", \"item_name\": \"Hand coffee grinder\",\n<\/span><span class=\"cbb-line\">            \"price\": 48.90, \"quantity\": 1 }\n<\/span><span class=\"cbb-line\">        &#93;\n<\/span><span class=\"cbb-line\">      }\n<\/span><span class=\"cbb-line\">    }\n<\/span><span class=\"cbb-line\">  &#93;\n<\/span><span class=\"cbb-line\">}<\/span><\/code><\/pre><\/figure>\n\n\n\n<p><code>transaction_id<\/code> is the receipt number and the only thing that keeps a repeated upload from counting a sale twice. <code>affiliation<\/code> carries the branch, which is the field that makes the data worth having at all. <code>session_id<\/code> and <code>engagement_time_msec<\/code> are what attach the event to a session in the reports; they are not required by the protocol, and their absence is not reported anywhere.<\/p>\n\n<p>Two type rules, both measured. An item <code>price<\/code> of <code>\"48.90\"<\/code> as text is accepted, <code>\"48,90\"<\/code> is refused with <code>Known item param [price] should have type [int64] or [double]<\/code>, and so are <code>\"abc\"<\/code> and an empty string. The event-level <code>value<\/code> is <strong>not<\/strong> checked the same way: <code>\"abc\"<\/code> passed there without a word. A locale-formatted amount from a German till therefore fails in one place and passes silently in the other.<\/p>\n\n<h2>The timestamp, and a window open at one end only<\/h2>\n\n<p>This is the field that decides whether an offline upload works at all, and both of its edges were measured rather than taken from the documentation.<\/p>\n\n<table>\n<tr><th>Offset from now<\/th><th>Verdict of the validation server<\/th><\/tr>\n<tr><td>71 h 59 min 55 s in the past<\/td><td>accepted<\/td><\/tr>\n<tr><td>72 h 00 min 00 s in the past<\/td><td>refused, <code>VALUE_INVALID<\/code><\/td><\/tr>\n<tr><td>0 s \u2014 the current second<\/td><td>accepted<\/td><\/tr>\n<tr><td>5 s in the future<\/td><td>refused, <code>VALUE_INVALID<\/code><\/td><\/tr>\n<tr><td>15 min in the future<\/td><td>refused<\/td><\/tr>\n<\/table>\n\n<p>The past edge is exact and exclusive: 72 hours to the second is already too old. The future edge is the surprise. The error text reads <code>Measurement protocol only supports timestamps [15m] into the future<\/code>, but every forward offset tested was refused, down to five seconds. The sending machine&#8217;s clock was within 0.8 seconds of the <code>Date<\/code> header returned by Google during the same run, so this is not drift on the measuring side.<\/p>\n\n<p>For a shop that means the till clock is a live risk. A checkout terminal running a minute fast produces receipts that the protocol treats as future events, and the production endpoint accepts them with 204 anyway. An uploader should compare the receipt time against its own trusted clock and hold anything dated forward instead of sending it.<\/p>\n\n<p>The value must be in microseconds. A timestamp in seconds is interpreted as a date in 1970 and returns <em>too far in the past<\/em>. That message can suggest a backdating problem when the actual mistake is the timestamp unit.<\/p>\n\n<h2>One request per customer, and why batching works differently here<\/h2>\n\n<p><code>client_id<\/code> and <code>user_id<\/code> sit at the top level of the payload, next to <code>events<\/code>, not inside an event. Moving a <code>client_id<\/code> into an event was refused outright: the payload stopped parsing, with <code>invalid JSON in google.analytics.measurement.Measurement @ events[1]<\/code>. The same happened for a <code>user_id<\/code> inside an event.<\/p>\n\n<p>So a request cannot mix customers. Batching twenty-five receipts from twenty-five different card holders into one call is not possible, and the uploader&#8217;s unit of work is the customer, not the batch.<\/p>\n\n<p>What <em>does<\/em> work is a per-event timestamp: <code>timestamp_micros<\/code> inside an event was accepted, including a payload carrying a top-level timestamp of one hour ago and a second event dated two hours ago. One request can therefore carry every receipt of one card from a whole period, each with its own time.<\/p>\n\n<p>The documented ceiling is twenty-five events and 130 kB per request. Neither is enforced by the validation server \u2014 twenty-six events passed, and one hundred receipts with two line items each came to 28.8 kB and passed as well \u2014 but a limit that is documented and unenforced is still a limit, and the reports are where the consequence would show up.<\/p>\n\n<h2>Checking before sending, because production never answers<\/h2>\n\n<p>The production endpoint returns <strong>204 No Content<\/strong> to everything. Measured with the payloads from this article: a valid receipt, a receipt dated thirty days back, a seconds-instead-of-microseconds timestamp, a four-letter currency code and a payload with no <code>client_id<\/code> at all \u2014 all 204, all in under 220 milliseconds, all with an empty body. The one with no <code>client_id<\/code> is the telling case, because the validation server refuses exactly that payload.<\/p>\n\n<p>The validation endpoint is the same URL with <code>\/debug<\/code> in front of the path. It checks neither <code>measurement_id<\/code> nor <code>api_secret<\/code>, so a payload can be checked without any credentials leaving the building.<\/p>\n\n\n\n<figure style=\"--cbb-digits:1\" class=\"cbb cbb--wrap cbb--numbers wp-block-code-beautifier-block-code\" data-lang=\"bash\"><figcaption class=\"cbb-head\"><span class=\"cbb-lang\">Shell<\/span><span class=\"cbb-actions\"><button type=\"button\" class=\"cbb-button cbb-button--wrap\" data-cbb-action=\"wrap\" aria-pressed=\"true\" title=\"Wrap long lines\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path d=\"M2 4h12M2 8h9.5a2.5 2.5 0 0 1 0 5H8m0 0 1.5-1.5M8 13l1.5 1.5M2 12h3\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\/><\/svg><span class=\"cbb-sr\">Wrap long lines<\/span><\/button><button type=\"button\" class=\"cbb-button cbb-button--copy\" data-cbb-action=\"copy\" data-cbb-done=\"Copied\" data-cbb-failed=\"Press Ctrl+C to copy\"><svg aria-hidden=\"true\" focusable=\"false\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8.5\" rx=\"1.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><path d=\"M10.5 3.5V3A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v6.5A1.5 1.5 0 0 0 4 11h.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"\/><\/svg><span class=\"cbb-button-label\" aria-live=\"polite\">Copy<\/span><\/button><\/span><\/figcaption><pre class=\"cbb-pre\" tabindex=\"0\"><code class=\"cbb-code language-bash\" style=\"counter-reset:cbb-line 0;\"><span class=\"cbb-line\"><span class=\"cbb-function\">curl<\/span> <span class=\"cbb-attr\">-s<\/span> <span class=\"cbb-attr\">-X<\/span> POST \\\n<\/span><span class=\"cbb-line\">  <span class=\"cbb-string\">'https:\/\/www.google-analytics.com\/debug\/mp\/collect?measurement_id=G-XXXXXXXXXX&amp;api_secret=placeholder'<\/span> \\\n<\/span><span class=\"cbb-line\">  <span class=\"cbb-attr\">-H<\/span> <span class=\"cbb-string\">'Content-Type: application\/json'<\/span> \\\n<\/span><span class=\"cbb-line\">  <span class=\"cbb-attr\">-d<\/span> <span class=\"cbb-string\">'{\"client_id\":\"2531008582.2372831494\",<\/span>\n<\/span><span class=\"cbb-line\"><span class=\"cbb-string\">       \"validation_behavior\":\"ENFORCE_RECOMMENDATIONS\",<\/span>\n<\/span><span class=\"cbb-line\"><span class=\"cbb-string\">       \"events\":&#91;{\"name\":\"purchase\",\"params\":{\"currency\":\"EUR\",\"value\":48.90}}&#93;}'<\/span><\/span><\/code><\/pre><\/figure>\n\n\n\n<p><code>validation_behavior<\/code> belongs in the check and nowhere else. Sent to the production endpoint it is an unknown field, and an unknown field invalidates the payload it was meant to protect.<\/p>\n\n<p>One property of the validation server shapes how it is used: it reports <strong>only the first fault it finds<\/strong>. Three problems in one event come back as one message, and the second appears only after the first is fixed. A payload built by a new integration therefore needs several rounds, which is the reason a validator that lists every fault at once exists at all.<\/p>\n\n<h2>What the check does not catch<\/h2>\n\n<p>With <code>ENFORCE_RECOMMENDATIONS<\/code> set, all of the following passed without a single message: a <code>purchase<\/code> with no <code>transaction_id<\/code>, with no <code>value<\/code>, with no <code>currency<\/code>, with no <code>items<\/code>, with an empty <code>items<\/code> array, and with no parameters at all. Two events sharing the same <code>transaction_id<\/code> in one request passed. A <code>user_id<\/code> of 257 characters passed, one character over the documented maximum. A currency code of <code>EURO<\/code> passed, and so did <code>eur<\/code> in lower case.<\/p>\n\n<p>None of that is a bug in the checker; it is the boundary of what a protocol validator can know. Whether a receipt has a number, whether an amount is a real amount, and whether the same sale has already been uploaded are questions about the shop&#8217;s data, and they have to be answered before the payload is built.<\/p>\n\n<h2>The upload rhythm, and what a weekly run costs<\/h2>\n\n<p>The 72-hour window turns the schedule into a design decision. For an uploader that runs at 03:00 and covers whole days:<\/p>\n\n<table>\n<tr><th>Rhythm<\/th><th>Oldest receipt at upload<\/th><th>Share outside the window<\/th><\/tr>\n<tr><td>every night<\/td><td>27 h<\/td><td>0 %<\/td><\/tr>\n<tr><td>every three days<\/td><td>75 h<\/td><td>4.2 %<\/td><\/tr>\n<tr><td>weekly<\/td><td>171 h<\/td><td>58.9 %<\/td><\/tr>\n<\/table>\n\n<p>A weekly run loses more than half of what it carries, and loses it silently: the endpoint answers 204 to the rejected receipts exactly as it does to the accepted ones. A nightly run has 45 hours of slack, which is what makes a failed night recoverable \u2014 a run that was missed can be repeated the next day and everything still lands.<\/p>\n\n<p>That spare time also makes it useful to keep failed uploads in a queue. Each unsent receipt still has its own deadline. Recording the receipt time in the queue lets the uploader identify which receipts remain within the upload window and which are already too old.<\/p>\n\n<h2>What this does not solve<\/h2>\n\n<p>Three limits are worth stating plainly.<\/p>\n\n<p>The measurements above describe what the <em>protocol<\/em> accepts. Whether a joined purchase then appears in a particular report depends on the property&#8217;s reporting identity and on whether <code>user_id<\/code> is collected on the website at all \u2014 that is a GA4 configuration question, not a protocol question, and none of it was measured here.<\/p>\n\n<p>Returns and cancellations have no comfortable place in this path. A <code>refund<\/code> event exists, but a return that arrives four days after the sale falls outside the window in exactly the way a late receipt does.<\/p>\n\n<p>What the window rules out, another path still allows. Receipts older than three days, returns that arrive a week later and any backfill of history do not belong in this protocol at all: for a property with the BigQuery export switched on, they belong in a join against the raw <code>events_*<\/code> tables, on the same identifiers this article builds. That route has no time limit. It is also the reason the 72-hour edge is a question of scheduling rather than a permanent loss \u2014 as long as the receipts themselves are kept.<\/p>\n\n<p>And the protocol itself is finished. Google put the Measurement Protocol into a finalized state in June 2026 \u2014 no removal, no new features \u2014 and points new server-to-server integrations at the Data Manager API. For a shop that already has a till exporting receipts, that is not an emergency: the limits measured here are the limits it will keep.<\/p>\n\n<div class=\"lw-faq\">\n<h2>Questions and answers<\/h2>\n\n<h3>What happens if the pepper has to be changed, for example after a leak?<\/h3>\n<p>Every hash changes, and with it the person history in GA4 breaks on that day. Three places are affected:<\/p>\n<ol>\n<li>The user_id. The website and the uploader compute it with the same pepper. If only one side switches, the result is exactly the two unrelated people the article warns about. Both therefore have to switch at the same moment.<\/li>\n<li>The mapping table. Its keys are old hashes, and an HMAC cannot be reversed. The table can only be rebuilt by hashing every card number from the customer records with both the old and the new pepper and replacing the old key with the new one.<\/li>\n<li>The derived client_ids. They come from the hash as well. Every card without a pairing appears as a new identity from the switch onwards, and the receipts before and after do not join up.<\/li>\n<\/ol>\n<p>A pepper change is therefore closer to a migration than to routine maintenance. The pepper also lives wherever the hash is computed, on the website and in the uploader, and both places need to protect it equally well.<\/p>\n\n<h3>Why a keyed hash rather than a plain SHA-256 of the card number?<\/h3>\n<p>Because there are too few card numbers. With an assumed format of a fixed prefix and eight digits, as in the example LW 4471 9930, only 100 million numbers are possible; hashing all of them without a key and matching each hash to its number does not take long on ordinary hardware. The pepper makes that calculation impossible for as long as it stays secret.<\/p>\n\n<h3>How does an uploader notice a failed upload if the production endpoint always answers 204?<\/h3>\n<p>Only from the connection itself: if no answer comes, the connection drops or a status other than 204 appears, the receipt goes into the queue. Everything concerning the content has to be settled before sending, because the endpoint will not report it afterwards. Four checks therefore belong in the uploader itself:<\/p>\n<ul>\n<li>The receipt time against its own clock: anything older than 72 hours no longer belongs in this protocol but on the BigQuery route, and anything dated in the future is held back.<\/li>\n<li>The formats: prices as a number with a decimal point, the client_id as &lt;number&gt;.&lt;number&gt;, the timestamp in microseconds.<\/li>\n<li>The fields whose absence the validation server lets through: transaction_id, value, currency and items.<\/li>\n<li>Every new payload shape once against the validation endpoint, in as many rounds as it takes until no message comes back, because it only reports the first finding.<\/li>\n<\/ul>\n<p>Whether the receipts actually arrived can only be checked afterwards in the property&#8217;s own data, with the BigQuery export switched on, for example by way of the receipt numbers.<\/p>\n<\/div>\n\n<div class=\"lw-quellen\">\n<h2>Sources<\/h2>\n<ul>\n<li><a href=\"https:\/\/developers.google.com\/analytics\/devguides\/collection\/protocol\/ga4\/reference\" target=\"_blank\" rel=\"noopener noreferrer\">Measurement Protocol (Google Analytics 4) \u2014 Reference<\/a><\/li>\n<li><a href=\"https:\/\/developers.google.com\/analytics\/devguides\/collection\/protocol\/ga4\/validating-events\" target=\"_blank\" rel=\"noopener noreferrer\">Validate events for Measurement Protocol<\/a><\/li>\n<li><a href=\"https:\/\/support.google.com\/analytics\/answer\/9213390\" target=\"_blank\" rel=\"noopener noreferrer\">Reporting identity in Google Analytics<\/a><\/li>\n<\/ul>\n<\/div>","protected":false},"excerpt":{"rendered":"<p>A till produces a receipt, a card number and a timestamp. GA4 knows browsers. This tutorial builds the bridge between the two with the Measurement Protocol: hashing the card number, recording the pairing while the customer is online, deriving a client_id for everyone else, and sending a purchase that lands. Every limit in it was measured on 9 September 2026, including the one the error message states wrongly.<\/p>\n","protected":false},"author":1,"featured_media":17815,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[113],"tags":[91261,91258,91219],"class_list":["post-17806","post","type-post","status-publish","format-standard","hentry","category-tutorials","tag-conversion-tracking","tag-google-analytics-4","tag-tutorial"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.1 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key | Lukas Wojcik<\/title>\n<meta name=\"description\" content=\"In-store purchases into GA4 with the Measurement Protocol: the loyalty card as join key, the measured 72-hour window, and a payload checked before sending.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key | Lukas Wojcik\" \/>\n<meta property=\"og:description\" content=\"In-store purchases into GA4 with the Measurement Protocol: the loyalty card as join key, the measured 72-hour window, and a payload checked before sending.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/\" \/>\n<meta property=\"og:site_name\" content=\"Lukas Wojcik - Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-19T05:50:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/hero-17806-ga4-measurement-protocol-offline-pur-n.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"630\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"Lukas Wojcik\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Lukas Wojcik\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"14 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/\"},\"author\":{\"name\":\"Lukas Wojcik\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"headline\":\"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key\",\"datePublished\":\"2026-10-19T05:50:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/\"},\"wordCount\":2456,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"image\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/hero-17806-ga4-measurement-protocol-offline-pur-n.png\",\"keywords\":[\"Conversion Tracking\",\"Google Analytics 4\",\"Tutorial\"],\"articleSection\":[\"Tutorials\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/\",\"name\":\"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key | Lukas Wojcik\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/hero-17806-ga4-measurement-protocol-offline-pur-n.png\",\"datePublished\":\"2026-10-19T05:50:00+00:00\",\"description\":\"In-store purchases into GA4 with the Measurement Protocol: the loyalty card as join key, the measured 72-hour window, and a payload checked before sending.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/#primaryimage\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/hero-17806-ga4-measurement-protocol-offline-pur-n.png\",\"contentUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/hero-17806-ga4-measurement-protocol-offline-pur-n.png\",\"width\":1200,\"height\":630,\"caption\":\"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/digital-analytics\\\/tutorials\\\/ga4-measurement-protocol-offline-purchases-loyalty-card\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/\",\"name\":\"Lukas Wojcik - Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\",\"name\":\"Lukas Wojcik\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"contentUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"width\":424,\"height\":636,\"caption\":\"Lukas Wojcik\"},\"logo\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\"},\"sameAs\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key | Lukas Wojcik","description":"In-store purchases into GA4 with the Measurement Protocol: the loyalty card as join key, the measured 72-hour window, and a payload checked before sending.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/","og_locale":"en_US","og_type":"article","og_title":"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key | Lukas Wojcik","og_description":"In-store purchases into GA4 with the Measurement Protocol: the loyalty card as join key, the measured 72-hour window, and a payload checked before sending.","og_url":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/","og_site_name":"Lukas Wojcik - Blog","article_published_time":"2026-10-19T05:50:00+00:00","og_image":[{"width":1200,"height":630,"url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/hero-17806-ga4-measurement-protocol-offline-pur-n.png","type":"image\/png"}],"author":"Lukas Wojcik","twitter_card":"summary_large_image","twitter_misc":{"Written by":"Lukas Wojcik","Est. reading time":"14 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/#article","isPartOf":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/"},"author":{"name":"Lukas Wojcik","@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"headline":"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key","datePublished":"2026-10-19T05:50:00+00:00","mainEntityOfPage":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/"},"wordCount":2456,"commentCount":0,"publisher":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"image":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/#primaryimage"},"thumbnailUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/hero-17806-ga4-measurement-protocol-offline-pur-n.png","keywords":["Conversion Tracking","Google Analytics 4","Tutorial"],"articleSection":["Tutorials"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/","url":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/","name":"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key | Lukas Wojcik","isPartOf":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/#primaryimage"},"image":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/#primaryimage"},"thumbnailUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/hero-17806-ga4-measurement-protocol-offline-pur-n.png","datePublished":"2026-10-19T05:50:00+00:00","description":"In-store purchases into GA4 with the Measurement Protocol: the loyalty card as join key, the measured 72-hour window, and a payload checked before sending.","breadcrumb":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/#primaryimage","url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/hero-17806-ga4-measurement-protocol-offline-pur-n.png","contentUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/hero-17806-ga4-measurement-protocol-offline-pur-n.png","width":1200,"height":630,"caption":"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key"},{"@type":"BreadcrumbList","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/digital-analytics\/tutorials\/ga4-measurement-protocol-offline-purchases-loyalty-card\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/www.lukaswojcik.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Uploading In-Store Purchases to GA4 With the Measurement Protocol: A Loyalty Card Number as the Join Key"}]},{"@type":"WebSite","@id":"https:\/\/www.lukaswojcik.com\/blog\/#website","url":"https:\/\/www.lukaswojcik.com\/blog\/","name":"Lukas Wojcik - Blog","description":"","publisher":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/www.lukaswojcik.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9","name":"Lukas Wojcik","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","contentUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","width":424,"height":636,"caption":"Lukas Wojcik"},"logo":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg"},"sameAs":["https:\/\/www.lukaswojcik.com\/blog"]}]}},"_links":{"self":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/17806","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/comments?post=17806"}],"version-history":[{"count":3,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/17806\/revisions"}],"predecessor-version":[{"id":22728,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/17806\/revisions\/22728"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/media\/17815"}],"wp:attachment":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/media?parent=17806"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/categories?post=17806"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/tags?post=17806"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}