Ecommerce analytics · Reproducible test

A valid-looking payload can carry the wrong order

Follow the identifier before blaming the report.

  1. Check the value as well as the typeA transaction_id can be a string and still contain digits that were rounded earlier.
  2. Preserve identity at the sourceSend the original order identifier as text before a JavaScript number can change it.
  3. Test a counterexampleLeading zeros can disappear even when the numeric value is comfortably inside the safe range.

Two purchases reach a tag with the same transaction ID. The obvious suspects are a reused variable, a duplicate tag or a checkout page that fires twice. But there is another possibility: the commerce system supplied different identifiers, and an intermediate conversion made them identical.

We reproduced that failure locally with two synthetic IDs, then tested the tempting repair of converting the parsed value back to text. It failed. Preserving the original identifier as a JSON string worked. A second case, using a short ID with leading zeros, shows why checking only for very large numbers is insufficient.

These are JavaScript data-preservation tests, run in Node.js v22.14.0 on 21 September 2026. They sent no events to GA4 and used no customer records. They demonstrate a failure mechanism, not its prevalence or a measured loss in any analytics account.

The payload can be valid and already wrong

Google’s GA4 purchase reference defines transaction_id as a required string. Its deduplication guidance says purchase events sharing an ID are deduplicated and warns against reusing IDs for different transactions. That guidance applies to web streams, not app streams.

That makes identity preservation a prerequisite for interpreting duplicates. If two genuine orders arrive with the same identifier, a system using that field cannot recover the distinction from the field alone. This is the risk implied by the documented rule; our local test does not reproduce Google’s ingestion pipeline.

Imagine an API response containing an order ID as an unquoted JSON number. The browser parses it, a tag variable calls String(), and the resulting payload passes a “must be a string” check. Every step can look reasonable in isolation. The decisive question is whether the digits still match the original order.

Two routes through JSON parsing: a quoted ID stays text and is preserved; an unquoted large ID becomes a number and may round.
The important boundary is the conversion into a number. This conceptual diagram describes a possible failure; the exact tested values appear in the table below.

Two adjacent identifiers survive only one route

The following self-contained example runs locally without a network request. The outer quotes hold the raw JSON as text. Inside that JSON, the first pair is numeric; the second pair is quoted. Keep those inner quotes exactly as shown.

const numeric = JSON.parse(
  '[9007199254740992,9007199254740993]'
);
const text = JSON.parse(
  '["9007199254740992","9007199254740993"]'
);
console.log(numeric.map(String));
// ["9007199254740992", "9007199254740992"]
console.log(text);
// ["9007199254740992", "9007199254740993"]

The exact identifiers after parsing

Original IDNumeric JSON → textQuoted JSON
900719925474099290071992547409929007199254740992
900719925474099390071992547409929007199254740993
Observed local outputs. The numeric route contains one distinct identifier; the quoted route retains two.

JavaScript’s maximum safe integer is 9,007,199,254,740,991. Above that boundary, consecutive integers are no longer all distinguishable as Number values. This does not mean every larger integer is represented inaccurately. In our pair, the first is representable; the second rounds to the same value.

The equality is therefore not evidence of a repeated order. It is evidence that this representation cannot distinguish the two inputs. Later formatting changes the type, not the lost information. Our test also confirmed that converting the damaged value to BigInt retains the damaged value.

The short ID that passes a precision check

Now suppose an integration receives the string "000123" and “cleans” it by converting it to a number. The result is 123, an entirely safe integer. Converting it back to text yields "123". A numeric precision check will find nothing wrong.

const original = "000123";
const changed = String(Number(original));
console.log(changed); // "123"
console.log(changed === original); // false
console.log(BigInt(original).toString()); // "123"

Whether those two strings identify different orders depends on the commerce system’s contract. The analytics integration should not silently make that decision. If leading zeros belong to the canonical ID, numeric conversion destroys identity even when it preserves mathematical value.

Move the repair before the first lossy conversion

The straightforward repair is to serialize the original, authoritative identifier as a string at the producer. In our test, the quoted JSON route retained both large IDs. Merely quoting a value after the producer has already rounded it will preserve the wrong digits perfectly. Inspect the earliest system that still holds the correct identity.

There is a useful exception to “parsing lost it.” MDN’s JSON.parse documentation describes context.source, available to the reviver in supporting runtimes. It exposes the original token before numeric conversion. Our Node test recovered both IDs by returning that source text for the numeric elements. Recovery was possible because the raw JSON still contained the original digits.

That is an option for a controlled parser when the upstream contract cannot immediately change. Check runtime support and restrict the transformation to the intended ID field. It cannot recover digits already lost before the JSON was produced. For normal commerce interchange, an explicit string contract avoids needing this special treatment.

At the receiving boundary, a guard can reject the wrong type instead of silently coercing it. The following is a minimal contract check, not a complete purchase-event implementation. It rejects blank input but returns a valid string unchanged.

function requireOrderId(value) {
  if (typeof value !== "string" ||
      value.trim() === "") {
    throw new Error("Expected an order ID string");
  }
  return value;
}

Put such validation in a controlled integration path with error handling and monitoring; do not make an analytics defect break checkout. Rejection should lead to diagnosis and recovery from the authoritative order record. It should not trigger a random replacement ID or an empty fallback. Google explicitly warns that empty transaction IDs cause purchase deduplication.

Follow one order and then try to break the match

For your own implementation, capture the authoritative ID, raw response, parsed value and outgoing purchase parameter using disposable test orders. Compare exact strings at each handoff. Include a large ID, any meaningful leading zeros and your real prefix format. Then repeat the same order: a retry should keep its identity rather than create a fresh one.

This separates two acceptance conditions. Different orders must remain distinguishable; repeated delivery of one order must remain recognizably the same order. Generating a fresh identifier whenever a tag fires satisfies the first condition superficially while defeating the second.

Correct matching matters after the original purchase too. Our Google Ads partial-refund example follows the remaining value of an order through adjustments. That is a different product workflow, but its arithmetic is useful only after the intended order has been identified.

The testing habit also applies to one-click unsubscribe: an interface that looks correct does not prove the underlying operation is correct. Here, a payload displaying the word “string” is insufficient evidence that the transaction identity survived.

If the exact IDs match throughout, this test has ruled out one mechanism for the inspected path. It has not explained every gap between orders and analytics. As with Pinterest clicks and website sessions, different measurement stages require their own evidence. Keep the result narrow enough to be useful: these identifiers survived this path, or this conversion is where they changed.

Sources and test boundaries

Documentation checked 21 September 2026. Sixteen local assertions passed, covering numeric and quoted JSON, late String and BigInt conversions, safe-integer boundaries, leading zeros, raw-source recovery and string-only validation. No GA4 ingestion test or customer experiment was performed.