Reading my Claude Code quota off an HTTP response header
I have run out of Claude Code quota mid-refactor more times than I want to admit. It refills eventually, but always empties at the point where stopping is most expensive, and the number that would have warned me lives behind a CLI I am not looking at.
So: a small screen on my desk. Two bars, the rolling 5-hour window and the week.
An ESP32, a 240x320 ILI9341 panel, WiFi, a poll loop. Building the firmware took the weekend I expected. What I had not budgeted for was a question I assumed was already answered. Which endpoint tells me how much quota is left?
One structural note, since everything below leans on it: a provider is a client object behind a common interface, and a registry polls the enabled ones on a timer. Two are registered, Claude Code and Kimi Code, and they answer that question in opposite ways.

The endpoint that won’t let you poll it
Claude Code does have a dedicated usage endpoint, /api/oauth/usage. It rate-limits at any polling interval (anthropics/claude-code#31637). Not aggressive polling. Any interval a desk display can reasonably use. So the number had to come from somewhere else.
There is also a Claude Code Analytics API, which is the first thing people point me at. It does not answer this question. It needs an Admin API key, and the docs say plainly that the Admin API is unavailable for individual accounts. Even with one it reports daily aggregates on a delay of at least an hour, and what it reports is sessions, lines of code, commits, tool acceptance rates, tokens and estimated cost. No utilization, no window, no reset time. It tells you what you spent last Tuesday. I wanted to know what is left right now.
Reading the meter by using it
Anthropic attaches unified rate-limit headers to Messages API responses:
anthropic-ratelimit-unified-5h-utilization
anthropic-ratelimit-unified-7d-utilization
anthropic-ratelimit-unified-5h-reset
anthropic-ratelimit-unified-7d-reset
Utilization is a float in 0.0–1.0; the resets are bare epoch seconds.
That is exactly the two bars I wanted, sitting in metadata on a request made for something else.
So make the request as small as it goes and throw the body away:
{"model":"claude-haiku-4-5-20251001","max_tokens":1,
"messages":[{"role":"user","content":"."}]}
The code comment calls it a 1-token Messages probe and credits “the claude-usage-stick approach”; I did not invent it, I needed it on a microcontroller.
Auth is the OAuth shape rather than an API key: Authorization: Bearer with the long-lived token from claude setup-token (sk-ant-oat01-…), plus anthropic-beta: oauth-2025-04-20 and the usual version and content-type headers.
There is also a line meant to make the probe look like the CLI:
http.addHeader("User-Agent", "claude-code/2.1.5");
That line does nothing. HTTPClient::addHeader opens with a guard commented “not allow set of Header handled by code”, and User-Agent sits in it next to Host, Connection and Accept-Encoding. The request line is built later from a member that defaults to ESP32HTTPClient, and the only way to change it is setUserAgent(), which I never call. Every probe I have ever sent announced itself as an ESP32.
Authorization survives the same guard on a technicality: it is only filtered when setAuthorization() has been used, and I set the header directly instead.
I found this while fact-checking the post, not while writing the firmware, and it is the same trap as the next section.
The line that has to come before the request
The project’s other provider client goes through a shared JsonHttp wrapper. This one bypasses it. Here the payload is in the headers and the body is irrelevant, so it drives Arduino’s HTTPClient directly. That has a trap in it:
http.collectHeaders(RL, 4); // the four header names above
int code = http.POST(/* probe body */);
String u5 = http.header(RL[0]);
HTTPClient discards every response header you did not register before the request runs. Move collectHeaders below the POST and all four reads come back empty. HTTPClient does not complain. Instead the guard below reports a bogus HTTP 200 failure that names nothing about the actual cause.
Success is not a status code
The probe does not need to succeed. It only needs to elicit the headers. My assumption, which the firmware never verifies, is that Anthropic attaches them to error responses too. So the failure test is header presence, not status:
if (u5.length() == 0 && u7.length() == 0) {
err_ = code == 401 ? "bad/expired token"
: code < 0 ? "no connection"
: "HTTP " + String(code);
return false;
}
code is consulted only to phrase the error afterwards. A 429 that still carries the headers is a perfectly good quota reading.
The blind spot: the firmware cannot notice a semantically broken probe as long as headers come back. The model string is pinned to one dated build, claude-haiku-4-5-20251001, and nothing here asserts on a model-not-found response. I print the status code to serial, but otherwise it only phrases an error that the header-presence gate never lets it reach. The day that model is retired, the panel keeps drawing whatever the last good headers said, and healthy bars are not evidence the probe still works.
The second asymmetry is mine. I wrote the guard as &&, so if only one utilization header arrives, the missing one runs through String::toFloat() on an empty string, becomes 0.0, and renders as a reassuring 0% bar. Swapping the operator alone would not fix it: that branch builds its message from the HTTP status, so a 200 missing one header would put “HTTP 200” on screen and throw away the window that did arrive. What it needs is a second check with its own reason. By comparison the reset headers degrade properly: a missing epoch yields 0, and fmtEta() renders nothing for an epoch of 0, an unsynced clock, or an already-past reset. The line disappears instead of lying.
The instrument spends what it measures
Every poll is a real API request that spends the same quota it is measuring. That is why this client is the only one that overrides the polling contract in both directions:
uint32_t minIntervalMs() const override { return 60000; }
bool allowsForcedRefresh() const override { return false; }
The registry raises the configured interval to that floor. That veto exists because saving config triggers a forced refresh, and a burst of saves should not run up usage on the account being measured.
Except that it doesn’t hold. Saving rebuilds the provider vector from the posted JSON, and the “preserve runtime state” block copies fourteen fields from the old entry. Not lastFetch. That scheduler test ends in && p.lastFetch && now - p.lastFetch < interval, so a zeroed lastFetch short-circuits it and each Save spends a probe anyway, past floor and veto alike. That one is a bug, not a design.
An OAuth device flow that fits inside fetch()
Kimi Code answers the same question with a plain GET /coding/v1/usages, so it overrides neither the floor nor the veto. Getting a token for it is the interesting part, and it has no interface of its own. fetch() is a three-line state machine over one field:
bool KimiCodeClient::fetch(Provider &p) {
err_ = "";
if (p.apiKey.length() == 0 && !runPairing(p)) return false;
if (!ensureAccessToken(p)) return false;
return fetchUsages(p);
}
apiKey holds the persisted refresh token, so an empty apiKey means “unpaired”. That aliasing buys NVS persistence, the web panel’s redacted hasKey boolean, the clearKey re-pair path and the registry’s keyless gate, with no new plumbing.
The one added hook is worksWithoutKey(), which splits “no key” into two states: unpairable, skip forever; or pairing, in which case the registry stops scheduling and calls fetch() every tick, trusting the client’s own nextPollMs. That is how a device-code flow fits inside a single-threaded Arduino loop() with no FreeRTOS task anywhere. runPairing() returns false until approval, which would normally mark the provider Failed; the registry suppresses that while a pair code is set, so the mode stays Waiting. That is the one mode where the display lets a provider draw its own screen.
It is RFC 8628-style, minus the parts I skipped: the device code’s expires_in is never read, so there is no pairing deadline, and the server’s interval survives one poll before the cadence is hardcoded to five seconds, so a slow_down is recognized and then answered at the same rate.
Worse, the pairing poll treats a dropped connection as a terminal rejection: postForm hands the empty body to deserializeJson, the parse fails, doc is left empty, doc["error"] reads empty, that matches neither authorization_pending nor slow_down, and the pair code the user is looking at is discarded and reissued. Twenty lines below, the refresh path checks for an actual error field first. Same distinction, present in one of two places.

What’s unfinished
The long-lived OAuth token goes out over client.setInsecure(). My shared transport header documents this as the v1 trust model with CA pinning as a known TODO, but this client bypasses that transport and its own setInsecure() line is unannotated. It’s the thing I’d fix first.
All of the security effort here sits at provisioning: no credential compiled in, and a first-boot password drawn from an alphabet with no 0/O and no 1/l/i because it gets read off an LCD. None of it sits at rest or in transit, where the token crosses an unverified connection and then lands in NVS as plaintext JSON.
And the whole thing rests on four response headers I have no contract for. Which is why every probe logs its status code and both utilization values to serial: when it breaks, I want it to break as HTTP 200 with empty 5h/7d, not as a plausible-looking zero.