Skip to content

API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"The request body is not valid JSON: no low surrogate in string: line 1 column 410559 (char 410558)"}}

Your request never became a request. The server read the bytes, tried to parse them as JSON, and stopped at one character — so nothing downstream ever saw a messages array to validate, let alone a model to run it. That rules out most of what people check first: it is not your API key (401), not your quota or credit balance, not the platform being busy (529), and not the model choking on the task. It is also not a size problem, even though the offset in the message looks alarming: a body that fails at character 410,558 is roughly 400 KB, and the Messages API accepts up to 32 MB.

Data as of 2026-09. Vendor limits and defaults change; check the official docs for current values before acting on any number below.

Where this 400 is generated, and what that rules out

A request to the Messages API passes through layers, and it is worth knowing them because all five of the invalid_request_error messages you can hit are produced at a different one:

  1. Byte-level parse. Is this valid JSON? This error lives here.
  2. Schema validation. Are the fields the right types, lengths and shapes? That layer is what produces messages with a field path, like tools.48.custom.name or messages.71.content.8.
  3. Semantic validation of the conversation. Do tool_use blocks have matching results, are immutable blocks unchanged.
  4. Account checks. Credit balance, configured spend limits.
  5. Inference. The model runs.

Everything that returns 400 invalid_request_error happens before step 5. Three consequences follow, and they are true for every error in this family:

  • The model never ran. There is no partial answer being withheld, no output tokens were produced, and you were not billed for generation. Whatever this cost you, it was not money.
  • The failure is deterministic. A validator handed identical bytes returns an identical answer. There is no queue, no capacity, no luck involved.
  • Your client’s retry machinery deliberately ignores it. The official SDKs retry connection errors, rate limits and 5xx responses with exponential backoff — twice by default. A 400 is not in that set, and Claude Code’s much more aggressive retry path (up to 10 attempts with backoff) does not cover it either. If you see this error at all, nobody retried it for you, because retrying it is known to be useless.

The one member of this family that behaves differently is the credit-balance 400: it is an account check rather than a payload check, so the identical bytes start working the moment the account changes. Every other one is your payload, and your payload is the only thing that can move.

The half a character that did it

“No low surrogate” is the parser naming a specific defect. Characters outside the basic multilingual plane — emoji, many CJK extension characters, some symbols — are represented in JSON escapes as a pair: a high surrogate followed by a low surrogate. Your body contains a high surrogate with nothing after it. Half a character.

Nothing you typed produced that. Halves get created when something slices a string at a position measured in units that are smaller than a character: a tool output truncated to a byte budget, a file read capped mid-character, a log line cut to a column width, a database column that stores UTF-8 in a fixed number of bytes. The slice lands between the two halves of one emoji and keeps the first one.

The part that makes this expensive: the half-character is now stored in your conversation transcript, and the Messages API is stateless — your client re-serializes the entire history on every single turn. So the defect is not in the request that failed; it is in the file that builds every request. This is why the session appears to die permanently, why a fresh prompt fails identically, and why restarting the CLI and resuming changes nothing. You are re-uploading the same broken byte every time.

Is retrying useful?

No. Not once, not with backoff, not after a wait. The bytes are the problem and nothing in a retry changes the bytes.

Retry exactly once if you want the confirmation — a second identical failure tells you the failure is deterministic, which is itself the diagnosis. A third attempt tells you nothing the second did not. If you have wrapped your own retry loop around this call, exclude 400 from it before you do anything else: a broken transcript inside a retry loop means re-uploading several hundred kilobytes repeatedly to be rejected at the same character every time.

Two moves that feel like retrying and are not worth trying either: switching models does nothing, because parsing happens before a model is selected; and /compact does nothing, because compaction rewrites the conversation through the same API, so the compaction request carries the same bad character and fails the same way.

Locating it when all you have is a character offset

The message gives you no field path — the parser never got far enough to know what field it was in. What it does give you is precise: line 1 (the body is minified onto one line) and a character index.

  • Dump the request body before it is sent. Whatever client you are using, capture the serialized JSON to a file. Then read the slice around the reported index — a few hundred characters either side is enough to identify which message and which tool output it sits in.
  • Search the transcript directly instead. For a CLI session the history is a JSONL file per session under ~/.claude/projects/<project>/. Scan it for code units in the surrogate range that are not followed by a partner. A lone high surrogate is not a normal thing to find; there will be one hit, not fifty.
  • Do not trust a local round-trip as a test. Serializing and re-parsing the body in your own process usually succeeds, because a lone surrogate survives as a \uD83D-style escape and most parsers accept the escape without checking that it pairs. Your body can pass your own JSON validator and still be rejected upstream. This is the single most misleading thing about the error: “but it’s valid JSON on my machine” is a reasonable thing to conclude and it is wrong.
  • Find when it started. The turn immediately before the first failure is the one that introduced the character. That turn’s tool output is your suspect.

Fixes, by how the surrogate got in

  • A truncated tool output or file read (the common case). Fix the truncation to cut on character boundaries rather than bytes or code units, then get the bad character out of the history. You cannot edit it away from inside the session — the request that would carry your edit is the request that fails. Rewind to a checkpoint before the poisoned turn, or start a fresh session and restate the task.
  • You drive the API yourself. Sanitize at serialization time: strip or replace unpaired surrogates in every string before the body is built. Doing it at ingest only is not enough, because content also arrives from tool results and from previous assistant turns that you are replaying.
  • A gateway or proxy in the path re-encodes the body. If the same payload succeeds when sent directly and fails through your proxy, the proxy is doing the slicing. Check anything that rewrites, logs or size-limits request bodies.
  • The content genuinely contains a broken character at rest. A file in the repository, a database row, a fixture. Repair it at the source, or the next session that reads it will die the same way.

Confirming the body is well-formed again

One successful request is not proof, because the broken character only has to be in one replayed message — a short turn that happens not to include it looks identical to a clean history.

Send a turn that carries the full conversation and triggers no tools at all — a plain question. If it returns normally, every stored message serialized and parsed end to end, which is exactly the property that was failing. Then re-run whatever produced the truncated output in the first place and verify the new output ends on a whole character. If you changed a truncation limit, the falsifiable check is that a string ending in a multi-byte character now comes back either complete or shortened to the previous whole character — never half of one.

If your 400 names a field path instead of a character offset, you cleared the parser and got stopped one layer later. messages: text content blocks must contain non-whitespace text is the same replay problem with an empty block instead of a broken one, and it is harder to locate because that message carries no index at all. A complaint about a tool name being too long comes from your tool definitions rather than your history, which is why it fails on the first turn of a brand-new session and this one does not. If the 400 says your credit balance is too low, it is the exception to everything above: same status code, same error type, but an account condition that clears without you touching the request. And if the message names tool use concurrency and tells you to run /rewind, the history is structurally broken rather than textually broken — same permanence, different repair. When you are not sure which layer rejected you, the AI coding error triage tool sorts these by the shape of the message: a character offset, a field path, or neither.