Most JSON bugs are not subtle. The payload is truncated, or a comma is in the wrong place, or the thing you thought was an object is actually a string that contains an object. The problem is rarely understanding JSON; it's having a repeatable way to find the break fast instead of squinting at a wall of minified text. This is the workflow I use when a response refuses to parse.
The usual ways JSON breaks
Before the workflow, it helps to know the failure modes you're looking for. The same handful of issues account for almost every "invalid JSON" error in practice:
- Truncated or incomplete responses. A connection drops, a buffer limit is hit, or a streaming response is read before it finishes. The JSON is valid right up until it just stops mid-token. The parser usually reports "unexpected end of input."
- Trailing commas. Valid in JavaScript object literals, invalid in JSON. A comma after the last element of an array or object kills the parse.
- Single quotes. JSON strings must use double quotes. Pasting a Python
dictor a JS object literal is the usual source. - Unquoted keys. Same source as above:
{name: "Alice"}is a JS object, not JSON. - BOM or leading whitespace. A byte-order mark (
) or stray characters before the opening brace will trip stricter parsers, even though the rest is fine. - Wrong Content-Type. The body is actually HTML (an error page) or form-encoded, but your client tried to parse it as JSON anyway.
- Double-encoded / stringified JSON. The single most confusing one. The value is a JSON string whose contents are themselves escaped JSON, full of
\"sequences.JSON.parsesucceeds and hands you back a string, not the object you expected.
The workflow
The order matters. Each step either fixes the problem or narrows down where it is.
1. Capture the raw body, unmodified
Get the bytes exactly as they arrived: curl -i, your proxy's raw view, or logging the response body before any parsing. Do not let your HTTP client pretty-print, re-encode, or "helpfully" deserialize it first. The whole point is to debug what was actually sent, not your client's interpretation of it. Keep the status line and headers too; you'll want the Content-Type.
2. Paste it into a formatter/validator
Drop the raw body into a JSON formatter and validator. A good validator does two things at once: it pretty-prints valid JSON so you can read the structure, and on invalid input it tells you the exact line and column where parsing failed. That error location is the fastest way to spot a trailing comma, a missing brace, or the point where a truncated payload was cut off.
3. If it's a stringified blob, un-escape it
If the formatter shows you a single long string covered in \" escapes rather than a structured object, you have double-encoded JSON. Parse it once to get the inner JSON string, then format that result. In practice: paste it, format it (which gives you the unescaped inner content), then format that inner content again to see the real structure. See the dedicated section below for a worked example.
4. Compare a working and broken payload
When the JSON parses but the behavior is wrong, structure is the suspect. Take a request that works and the one that doesn't, pretty-print both, and run them through a diff checker. Pretty-printing first is what makes the diff readable: minified JSON diffs as one giant line. The diff shows you the field that's missing, the type that changed from number to string, or the null that should have been an array.
5. Lock the shape by generating types
Once you have a payload you trust, stop eyeballing it. Feed a representative sample to JSON to TypeScript and commit the generated interfaces. From then on the compiler catches a renamed field or a changed type before it reaches runtime, which is where most of these bugs started.
Debugging webhook bodies
Webhooks add one rule that overrides everything above: verify the signature over the raw bytes before you touch the body. Providers like Stripe, GitHub, and Shopify compute an HMAC over the exact bytes they sent. If you parse and re-serialize first, you change whitespace, key order, and escaping, the recomputed HMAC no longer matches, and a perfectly legitimate webhook fails verification.
So the order for webhooks is: read the raw body as bytes, verify the signature against those bytes, and only then deserialize and reformat for debugging. Many frameworks parse the body automatically; for webhook routes you usually need to opt out and grab the raw buffer.
Two more things that bite people with webhooks:
- Base64-encoded fields. Some providers wrap binary or nested payloads as base64 inside a JSON field. Decode it with a base64 encoder/decoder to see what's actually in there, often it's another JSON document.
- Content-Type quirks. Not every webhook sends
application/json. Some sendapplication/x-www-form-urlencodedwith the JSON stuffed in a single field. Check the header before assuming the body is bare JSON.
Escaped / double-encoded JSON
This deserves a concrete example because it's the one that wastes the most time. Suppose an API returns:
{
"event": "order.created",
"payload": "{\"id\":1,\"total\":42}"
}
The value of payload is a string, not an object. Its content is "{\"id\":1,\"total\":42}" — every inner quote is escaped with a backslash because the whole thing is a JSON string. Parsing the outer document gives you:
{ event: "order.created", payload: "{\"id\":1,\"total\":42}" }
// payload is a string. payload.id is undefined.
To peel one layer, parse the value of payload a second time:
const outer = JSON.parse(body); const inner = JSON.parse(outer.payload); inner.id; // 1 inner.total; // 42
The smallest version of the problem is worth memorizing: the string "{\"id\":1}" is not an object, it's a six-character-key string. One JSON.parse turns it into { id: 1 }. If you see backslash-quote pairs in a formatter, that's your signal to parse one more time.
JSON.stringify twice, or stored a JSON string in a database text column and serialized the row without re-parsing it. Fix it at the source if you own it; peel it at the edge if you don't.
Common mistakes
- Reformatting before signature verification. Covered above, but it's worth repeating because the symptom — valid-looking JSON that fails HMAC checks — sends people down the wrong path. Verify against raw bytes first, always.
- Trusting a 200 with an HTML error body. A 200 status does not mean the body is the JSON you wanted. Load balancers, CDNs, and auth gateways happily return
200with an HTML error or login page. If parsing fails and the body starts with<!doctype html>, the JSON was never the problem. Check the Content-Type. - Assuming UTF-8. JSON should be UTF-8, but a misconfigured upstream can send Latin-1 or sneak in a BOM. Mojibake in string values or a parse error on the very first byte points at an encoding mismatch, not a syntax error.
Related tools
- JSON Formatter & Validator — pretty-print, validate, and get the exact error location
- JSON Editor — edit and re-check structure interactively
- JSON to TypeScript — lock the shape with generated interfaces
- Diff Checker — compare a working and broken payload
- Base64 Encoder / Decoder — decode base64-wrapped fields in webhook bodies
Related guides
- Local-First Dev Tools — why pasting sensitive payloads into in-browser tools keeps them off the wire
- JWT Decoding Safely — inspecting token payloads without leaking them
- JSON Guide — the full reference on syntax, schema, and security