Claude Code "Invalid API key · Fix external API key": Not logged in, Credit balance is too low, API Error 401/429/529 — Exact Messages and Retry Behavior, Tested
Background
Point Claude Code at a compatible endpoint like DeepSeek, swap in a new key, or run out of credit, and claude -p will often print one of these:
Invalid API key · Fix external API key
Not logged in · Please run /login
Credit balance is too low
API Error: Request rejected (429) · …
API Error: 529 Overloaded. This is a server-side issue, usually temporary — …
The messages are readable enough. The hard questions are these:
- Did Claude Code decide this locally, or did the backend return it? Was a request even sent?
- Why does a wrong key make the command hang for almost 3 minutes before failing?
- In a script, is the error on stdout or stderr? What is the exit code? Which field in
--output-format jsonshould you check? - If you run a third-party gateway, should you read
x-api-keyorAuthorization: Bearer?
Every message and number below comes from runs on Claude Code 2.1.280 on this machine on 2026-09-27: 28 cases, 61 claude -p runs in total. The backend is not the real API but a local stub, so the status code can be anything I want, and the arrival time and headers of every request are recorded.
Analysis
I first located the definitions of these messages in the 2.1.280 native binary (claude.exe, 217 MB):
var _Ye="Not logged in \xB7 Please run /login",
Gke="Invalid API key \xB7 Fix external API key",
pat="Invalid auth token \xB7 Fix external auth token",
mat="Invalid ANTHROPIC_CUSTOM_HEADERS \xB7 Fix the environment variable"
There is also a check: function JM(e){return/^[a-zA-Z0-9-_]+$/.test(e)}, which throws Invalid API key format. API key must contain only alphanumeric characters, dashes, and underscores. on failure.
From the static code alone it is tempting to conclude that "a key with special characters is rejected locally". The test run disproved this: ANTHROPIC_API_KEY='bad key!@#' was sent to the backend verbatim in x-api-key, the stub returned a normal response, and the run exited 0. Going back to the code, the only call site of edo() is in the OAuth API-key creation flow (r.data?.raw_key → edo(s,n)); a key from an environment variable never passes through it. So every conclusion here rests on the runs; static code is only used to explain what was observed.
Reading the code also turned up the logic that picks the message, later confirmed by the runs: whether a 401 shows Invalid API key depends on whether the error message contains the string x-api-key:
function uat(e){return e instanceof Error&&e.message.toLowerCase().includes("x-api-key")}
// ...
if(uat(e)){ …; return Ro({error:"authentication_failed",
content: M==="ANTHROPIC_API_KEY"||M==="apiKeyHelper" ? Gke : _Ye}) }
Approach and alternatives
The goal is to assert "was a request sent, how many times, at what intervals, with which header", so I need control over what the backend returns and a record of every request that arrives.
| Option | Verdict | Reason |
|---|---|---|
| Local stub (zero-dependency Node) | ✅ Used | Any status code and body; every request logged to jsonl with a millisecond timestamp and all headers; costs nothing and bothers no real service |
| Real key against api.anthropic.com | ❌ Rejected | Can't produce 429/529/5xx on demand; a wrong key retried over and over is pointless load on the official API |
| A real third-party endpoint (DeepSeek, etc.) | ❌ Rejected | The error body format is theirs, not mine, so variables can't be controlled; the same error may come back differently at different times |
| mitmproxy capture | ❌ Rejected | Needs certificate trust changes and adds another variable; ANTHROPIC_BASE_URL can point straight at an http stub, so it isn't needed |
| Reading strings in the binary only | ❌ Not on its own | The format check above is the counterexample: it exists in the code but does not fire on this path |
How it works:
lab/stub.mjslistens on127.0.0.1and, per case config, answersPOST /v1/messageswith valid SSE, a chosen status code and body, or a dropped connection.- Each run starts with
env -iand a brand-new emptyCLAUDE_CONFIG_DIR, so the machine's OAuth login is never reused; the working directory is separate too. HTTPS_PROXYalso points at the stub: anyCONNECTis logged with its target host and answered 403. So every outbound connection is recorded and blocked, guaranteeing no request in this experiment reached the real api.anthropic.com.- Invocation is always:
claude -p 'reply with exactly OK' --model haiku --output-format text|json < /dev/null.
Test runs
The control run goes first. The stub returns a valid Messages stream (message_start → content_block_delta: "OK" → message_stop). All 3 runs exited 0 with stdout byte-for-byte 4f 4b 0a (OK\n); the stub got 1 request each, carrying x-api-key, anthropic-version: 2023-06-01, and user-agent: claude-cli/2.1.280 (external, sdk-cli). The stub is trustworthy, so the failures below can be attributed.
Every case ran at least twice, and the message, exit code and request count matched every time. This table is the core of the article. "Stub requests" is what the stub actually logged, not an inference:
| Message (stdout) | Trigger | Local or backend | Stub requests | Retried? | Wall time |
|---|---|---|---|---|---|
Not logged in · Please run /login |
Neither ANTHROPIC_API_KEY nor ANTHROPIC_AUTH_TOKEN set, not logged in |
Local | 0 | — | 0.28 / 0.30 s |
Invalid API key · Fix external API key · Invalid X-Api-Key header value from ANTHROPIC_API_KEY: it contains a non-ASCII character at character 4 (9 characters). |
Key contains Chinese (sk-密钥-123) |
Local | 0 | No | 0.28 / 0.27 s |
… it contains a line break at character 7 (10 characters on 2 lines). |
Newline in the middle of the key | Local | 0 | No | 0.27 / 0.27 s |
Invalid auth token · Fix external auth token · Invalid Authorization header value from ANTHROPIC_AUTH_TOKEN: it contains a non-ASCII character at character 5 (10 characters). |
Token contains Chinese | Local | 0 | No | 0.28 / 0.28 s |
Invalid API key · Fix external API key |
Backend 401, message contains invalid x-api-key |
Backend | 11 | Yes, 10 times | 179.43 / 168.78 s |
| Same as above | Backend 401, type changed to invalid_request_error, message still contains x-api-key |
Backend | 11 | Yes | 176.93 / 183.28 s |
Failed to authenticate. API Error: 401 invalid api key |
Backend 401, message is invalid api key (no x-api-key) |
Backend | 11 | Yes | 180.78 / 176.96 s |
Not logged in · Please run /login |
Only ANTHROPIC_AUTH_TOKEN set, backend 401 |
Backend | 11 | Yes | 181.07 / 179.16 s |
Credit balance is too low |
402 billing_error, message is the official credit balance text |
Backend | 1 | No | 0.93 / 0.75 s |
Credit balance is too low |
400 invalid_request_error, same message |
Backend | 1 | No | 0.99 / 0.71 s |
Failed to authenticate. API Error: 403 Your API key does not have permission to use the specified resource. |
403 permission_error |
Backend | 1 | No | 0.98 / 0.72 s |
API Error: 400 Model Not Exist |
400 invalid_request_error |
Backend | 1 | No | 0.96 / 0.68 s |
There's an issue with the selected model (claude-haiku-4-5-20251001). It may not exist or you may not have access to it. Run --model to pick a different model. |
404 not_found_error |
Backend | 2 | One extra non-streaming request only | 0.95 / 0.77 s |
API Error: Request rejected (429) · Number of request tokens has exceeded your per-minute rate limit |
429 + retry-after: 1 |
Backend | 11 | Yes, 10 times | 179.06 / 184.21 s |
API Error: 529 Overloaded. This is a server-side issue, usually temporary — try again in a moment. If it persists, check your inference gateway (127.0.0.1:18913). |
529 overloaded_error |
Backend | 11 | Yes | 180.24 / 180.05 s |
API Error: 500 Internal server error. …check your inference gateway (…) |
500 api_error |
Backend | 11 | Yes | 179.81 / 180.51 s |
API Error: 502 Bad Gateway. …check your inference gateway (…) |
502 + nginx HTML page | Backend | 11 | Yes | 178.63 / 171.14 s |
API Error: API returned an empty or malformed response (HTTP 200) — check for a proxy or gateway intercepting the request. … |
200, but body is HTML or empty | Backend | 2 | One extra non-streaming request only | 0.96 / 0.78 s |
API Error: Connection dropped (ECONNRESET) |
Stub drops the connection after receiving the request | Backend (network) | 11 | Yes | 185.36 / 171.91 s |
API Error: Connection refused — a firewall or proxy may be blocking it (ECONNREFUSED) |
Nothing listening on the base URL port | Local network layer | 0 | Yes, full retries anyway | 184.27 / 182.76 s |
Two things deserve attention:
- The same message can have two sources.
Invalid API key · Fix external API keycan be a backend 401 (3 minutes of retries) or the local header check (0.28 s, followed by· Invalid X-Api-Key header value…). Same withNot logged in: either there are no credentials locally at all, or a token was rejected with 401 by the backend. The latter sends you off to run/loginwhen what you actually need is a new token. - The format check never fired. With
ANTHROPIC_API_KEY='bad key!@#'both runs exited 0 and printedOK; thex-api-keyvalue the stub received was exactlybad key!@#.Invalid API key format…was not reproduced. Also, a trailing newline on the key is not an error: the stub receivedsk-abc123with the newline stripped.

Retry timing: even a 401 is retried 10 times
Request intervals the stub logged for the 401 case (ms, r1):
545, 1020, 2254, 4300, 8248, 17336, 34291, 34866, 39673, 36396
That is roughly 0.5 → 1 → 2 → 4 → 8 → 16 s, doubling each time, capped at 32–40 s from the 7th retry on. It gives up after 10 retries; first-to-last span is 168.5–184.8 s. The 11 data sets for 429, 529, 500, 502 and ECONNRESET follow the same curve. Every request carried x-stainless-retry-count: 0, which means this is Claude Code's own application-level retry, not the SDK's.
429's retry-after: 1 is honored: the first interval across 3 runs was 1009 / 1005 / 1006 ms, while for other status codes it was between 529 and 628 ms. After that, intervals match the other status codes.
The defaults in the code match what was measured: Vur=10 (default retry count) and GCe=15 (the cap for CLAUDE_CODE_MAX_RETRIES; larger values are clamped with a warning). There is also Yur=300, a default for some mode; what triggers it was not determined.

Turning retries off: CLAUDE_CODE_MAX_RETRIES
Same 401 stub, with just one environment variable added:
| Setting | Stub requests | Intervals | Wall time (2 runs) | stdout |
|---|---|---|---|---|
| Default | 11 | see above | 179.43 / 168.78 s | Invalid API key · Fix external API key |
CLAUDE_CODE_MAX_RETRIES=2 |
3 | 628, 1082 / 548, 1124 ms | 2.01 / 1.97 s | same |
CLAUDE_CODE_MAX_RETRIES=0 |
1 | — | 0.28 / 0.27 s | same |
stdout, stderr, exit code
Across all 61 runs, stderr was 0 bytes every time; every error went to stdout with a trailing \n; failures were always exit 1, successes exit 0. In practice:
2>err.logcaptures nothing, and any "empty stderr means success" check will be wrong.- The exit code tells you something failed, but not whether it was a bad key, a rate limit, or a dead gateway — it's 1 for all of them.

What --output-format json looks like
Key fields from the three json-mode runs:
| case | subtype |
is_error |
api_error_status |
terminal_reason |
result |
duration_ms |
|---|---|---|---|---|---|---|
| No credentials | "success" |
true |
null |
"api_error" |
Not logged in · Please run /login |
130 |
| 401 | "success" |
true |
401 |
"api_error" |
Invalid API key · Fix external API key |
171725 |
| 429 | "success" |
true |
429 |
"api_error" |
API Error: Request rejected (429) · … |
182737 |
subtype is still "success" on an API error, so checking it alone will read failure as success. Check is_error or terminal_reason instead. The result key is present and holds the error message. This differs from the Reached max turns article, where the subtype is error_max_turns and the result key may be missing entirely. api_error_status tells you the source: backend errors carry the HTTP status, local credential problems are null.
Third-party gateways: which header is actually sent
| Environment variables | Auth header received by the stub |
|---|---|
Only ANTHROPIC_API_KEY |
x-api-key: <key> |
Only ANTHROPIC_AUTH_TOKEN |
authorization: Bearer <token> |
| Both | Both are sent: x-api-key: sk-stub-KEY-111 + authorization: Bearer tok-stub-TOKEN-222 |
With both set, Claude Code doesn't choose for you: the gateway receives both headers and picks by its own precedence. If the two values belong to different accounts, problems get very hard to trace.
Results
The readings, turned into things you can act on:
1. Client side: set only one of ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN. If the gateway docs ask for Bearer, set only TOKEN; if they ask for x-api-key, set only KEY. Then verify quickly with this instead of waiting 3 minutes:
CLAUDE_CODE_MAX_RETRIES=0 claude -p "reply with exactly OK" --output-format json < /dev/null \
| jq '{is_error, api_error_status, result}'
api_error_status is null with is_error:true → the problem is local credentials (not set, or containing non-ASCII characters or an embedded newline); 401 → the backend rejected the key.
2. Seeing Not logged in · Please run /login? Check which variable you set first. If it's ANTHROPIC_AUTH_TOKEN, this most likely means the token got a 401, and running /login won't help.
3. In scripts / CI: decide success by exit code or is_error, and read the error text from stdout. For health-check-style calls, set CLAUDE_CODE_MAX_RETRIES=0 or a small value, or one misconfigured key stalls the pipeline for 3 minutes. Don't set it to 0 for long-running jobs — 429/529 are exactly what backoff retries are for.
4. Gateway side (for people building a compatibility layer):
- Accept both
x-api-keyandAuthorization: Bearer; when a request carries both, define your precedence explicitly. - If you return 401 for an invalid key, Claude Code retries 10 more times, so one failed call reaches you as 11 requests. 403 and 400 were not retried in the runs (1 request). Whether to change your status code over this depends on your semantics; this is just the measured behavior.
- Put the string
x-api-keyin the 401 message and users seeInvalid API key · Fix external API key; leave it out and they seeFailed to authenticate. API Error: 401 <your message>. Both were tested. - For insufficient credit, as long as the message contains
credit balance is too low, both 402 and 400 display asCredit balance is too low. A bare 402 whose message lacks that text was not tested. - 5xx errors include
check your inference gateway (<your host:port>), so users will suspect your gateway first.
5. More base URL pitfalls: for ANTHROPIC_BASE_URL being ignored, see this article; for a full DeepSeek setup, see Running Claude Code on DeepSeek.
All 61 runs hit the local stub, so real API spend was 0. The total_cost_usd: 0.000015 in the control run's JSON is the stub's returned usage (10 input / 1 output tokens) priced at haiku rates, not a real charge.
Pitfalls
- Claude Code still connects to api.anthropic.com with
ANTHROPIC_BASE_URLset. Every run issuedCONNECT api.anthropic.com:443: 5 times for a normal run, 2 with no credentials, 2 with only a token, and 18–23 for runs that used all 10 retries. 690 in total, all targetingapi.anthropic.com:443, all blocked by the stub proxy with 403 — and the control run still exited 0. What these connections are for isn't in the debug log; not attributed. If your network can't reach that domain, this probably doesn't affect the main request (it was blocked throughout this run), but that is the only network condition tested. - I trusted a static clue at first. The original plan featured "malformed key → local
Invalid API key format" as the headline; in practicebad key!@#simply succeeded. A string in the binary only proves the code contains it, not that it fires on your path. - Local
grepon a large binary is too slow. BSD grep with.{90}fragment.{140}over 217 MB took about 35 minutes to return 2 fragments; I switched to python mmap to find literal offsets and slice around them. This was already recorded in the Bash timeout article, and I made the same mistake again. - Each retry case takes about 3 minutes. The first pass ran the 4xx group serially; only after 600+ s did I realize 401 was being retried too. I stopped it and switched to one stub per case, run in parallel. The interrupted run was redone; the table shows the rerun data.
- About the screenshots. The output text in all three images is the raw stdout bytes captured by the experiment runner. The
realline is the wall time recorded by the runner, not actual output from the shell'stime. In image 2,sk-wrongin the command is a display placeholder; the key actually used wassk-stub-valid-123, and the stub returned 401. In image 1 the key was passed via environment variable (sk-密钥-123) and isn't shown on the command line. Separately, 5 fast cases were recorded on a real TTY with script(1) and matched the runner's stdout byte for byte (-ptext mode has no color).
Not verified: Invalid API key format… (not reproduced; only in the OAuth key-creation flow); Invalid ANTHROPIC_CUSTOM_HEADERS (not tested); 402 without the credit text in the message (not tested); a 429-then-success recovery sequence (not tested); display and retries in an interactive (non--p) session (not tested); what triggers Yur=300 and what those CONNECTs are for (not attributed). Each case ran only twice, so the jitter range of the retry intervals is based on those samples alone.