Tutorial: Checking a JSON Payload Before It Goes Into a Tag

Contents
A payload that a parser rejects is annoying and harmless: something breaks immediately, in one place, and the fix takes a minute. A payload that a parser accepts and reads differently than intended is neither of those things.
Both kinds come from the same source. JSON looks like a JavaScript object and is a much narrower format, and the differences between them are exactly the list below.

Why a JavaScript Object Is Not JSON
Everything valid in JSON is valid JavaScript. The reverse does not hold, and the gap between them is where the errors live.
{ // JavaScript, not JSON
'order_id': 9007199254740993, // single quotes
total: 129.90, // unquoted key
items: [ { sku: "SKU-42", qty: 2, } ], // trailing comma
rabatt: NaN, // not a JSON value
/* a comment */
}
JSON permits double quotes and nothing else, requires every key to be quoted, forbids a comma before a closing bracket, has no comments, and knows exactly seven value types: object, array, string, number, true, false and null. There is no undefined, no NaN, no Infinity and no date.
That last point is worth stating plainly, because it produces a recurring surprise: a date has no representation of its own. Whatever arrives is a string or a number that both sides have agreed to read as a date, and any disagreement about the format is invisible to the parser.
The Five Errors That Raise an Exception
These are the loud ones, and every one of them produces the same unhelpful message about an unexpected token.
| Error | What it looks like |
|---|---|
| Single quotes | ‘order_id’ instead of “order_id” |
| Unquoted key | total: 129.90 instead of “total”: 129.90 |
| Trailing comma | A comma before } or ] |
| Comment | Anything after // or between /* and */ |
| Raw line break in a string | A newline inside quotes; it has to be \n |
A sixth belongs in the same group without being visible at all: a byte order mark at the beginning of the file. It is three invisible bytes before the opening brace, and the parser reports an unexpected token at position 0 – which looks like the brace is the problem. A file that looks flawless and fails at position 0 has one, and an editor set to save without a BOM removes it.
The Two That Report Nothing
These are the ones worth hunting for on purpose, because nothing else will point at them.
The first is a duplicate key. The specification allows it, and the common behaviour is that the last one wins – so a document with "total" twice parses cleanly and carries the second value. Where the two are far apart in a long payload, the result is a number that is neither wrong-looking nor right.
{ "total": 129.90, "currency": "EUR", "total": 139.90 }
JSON.parse(...).total → 139.90
The second is a large integer. Numbers in JavaScript are floating point, and integers stay exact only up to 2^53 − 1. An order number, an ID from a database, a Meta CAPI identifier – all of them regularly exceed that, and the parser rounds them without a word.
JSON.parse('{"id": 9007199254740993}').id → 9007199254740992
JSON.parse('{"id": "9007199254740993"}').id → "9007199254740993"
The rule that follows is short: an identifier is a string, always, even when it consists only of digits. Nothing is ever calculated with it, and a string survives every parser unchanged.
Reading the Position a Parser Reports
The position in the error message is a character offset into the whole document, which in a minified payload is a number without meaning.
The first step is therefore always to format the document, not to look for the error. A formatted payload puts the position onto a line, and the line above the reported one is usually where the fault actually is – a missing comma is noticed at the next token, not at the place it is missing from.
try {
JSON.parse(text);
} catch (e) {
const stelle = Number((e.message.match(/position (\d+)/) || [])[1]);
if (!Number.isNaN(stelle)) {
console.log(text.slice(Math.max(0, stelle - 60), stelle + 60));
console.log(" ".repeat(Math.min(60, stelle)) + "^");
}
}
Sixty characters either side of the position is enough to see the problem in almost every case, and the caret removes the counting. This is worth having as a snippet, because the alternative is opening a formatter, pasting, scrolling and losing the position.
Checking It on the Command Line
Where the payload comes out of a file or a request, a single command answers the question.
# valid? formatted output, or an error with a line number
jq . nutzlast.json
# only the answer, without the content
jq -e . nutzlast.json >/dev/null && echo "gueltig" || echo "ungueltig"
# find duplicate keys - --stream reports every pair, including the repeated one
jq -c --stream 'select(length == 2) | .[0]' nutzlast.json | sort | uniq -d
# large numbers that will lose precision
grep -oE '"[a-z_]+": *[0-9]{16,}' nutzlast.json
The third command is the one worth running on a payload that has never been checked. It lists the paths that occur more than once, which is a mechanical way to find the silent case – and on a generated payload it finds something surprisingly often, because a template that appends a field in two branches produces exactly this.
Where It Matters Most
Three places turn a small JSON mistake into something that is hard to notice afterwards.
A JSON-LD block in the page is the first. A syntax error means search engines read no structured data at all, and the page keeps working perfectly – so nothing about the site suggests a problem, and the loss appears weeks later as missing rich results. A validator on the rendered page, not on the template, is the check that catches it.
A conversions API payload is the second. The receiving platform answers with an error, but that error is on a server-to-server response nobody is watching, and a failed event simply does not appear. This is the case where the duplicate key does the most damage: a payload with an event value twice sends the wrong one, and both the sender and the receiver consider the request successful.
A tag manager template configuration is the third. Its JSON is read once when the template is loaded, and a malformed field can leave the template installed but inert. What makes this one different is that it fails at the moment of import rather than at runtime – which is a better place to fail, and only if somebody reads the message.
What all three have in common: they are checked at the point where the payload is produced, not where it is consumed. A build step that runs the parser over every generated file costs nothing and catches all five loud errors before they leave the machine.
Questions and answers
With a duplicate key, does the last value win in every parser?
No, and that is where the greater danger lies. The JSON specification, RFC 8259, only says that names within an object should be unique, and it states explicitly that receivers behave differently when they are not: many keep the last value, others report an error, and some pass on all the pairs. JSON.parse, Python’s json module and jq keep the last one.
This becomes a problem as soon as two places read the same payload with different parsers. If a validator checks the first value and the receiver processes the last, the check has seen a different value from the receiver, and both sides report success.
Code that parses the payload itself can reject the case outright. In Python an object_pairs_hook that counts the keys of each object and raises an exception on a duplicate is enough; the silent case then becomes as loud as the five in the table.
Does jq . show a large number exactly as it appears in the file?
That depends on the version. Up to jq 1.6 every number is read as a floating-point value, and the formatted output shows 9007199254740993 as 9007199254740992, just like JSON.parse, so a saved copy of that output has lost the digit; only since jq 1.7 does a number from the file stay unchanged as long as no arithmetic is done on it. The reliable check is therefore the grep command from the article, because it reads the text of the file rather than the parsed value.