I wanted a keyboard shortcut that pops my claude.ai usage stats into a desktop notification. The settings page works fine, but checking it means opening a tab, navigating to settings, switching to the usage panel, and waiting for it to load. I wanted one keypress.
What I thought would be a quick scrape turned into a small tour of how Cloudflare actually decides whether you're a browser — and a reminder that a session cookie is a password. This is the build log: what I did, what broke, and what I now know that I didn't before. The finished tool is on GitHub.
Step zero: is this even possible?
claude.ai doesn't expose a public API for chat usage. The Admin API over on the console covers API consumption — a separate billing surface — not Pro-plan chat usage. So if I wanted this number from a script, scraping was the only path.
Three ways to scrape it: drive a real browser with Playwright (heavy), reverse-engineer the internal API (fragile but light), or reuse the session my logged-in Firefox already has by reading its cookies. The third is the cleanest — no second login, no API key, no automation framework. Just "do what the browser does, but from Python."
Finding the endpoint
claude.ai → DevTools → Network tab → filter to XHR →
reload the usage page. About thirty requests came back; one was literally
named usage. The URL was
https://claude.ai/api/organizations/<org-uuid>/usage,
and the response was clean JSON — five_hour,
seven_day, and a pile of nulls and internal codename fields
(tangelo, iguana_necktie…) that I ignored.
DevTools has a right-click → Copy → Copy as cURL that dumps the entire request — every header and cookie — as a runnable command. Great for reconnaissance. Also the source of my first mistake.
Mistake #1: I leaked my own session cookie
When I copied that cURL command out to look at it, it carried the live
sessionKey cookie with it. That cookie is functionally my
password — anyone holding it is logged in as me, no credentials
needed. The moment I realized it had left a safe place, I logged out of
all sessions to invalidate it.
Cookie: line
gets redacted. Like this:
curl 'https://claude.ai/api/organizations/XXXX/usage' \
-H 'User-Agent: Mozilla/5.0 ...' \
-H 'Cookie: XXXXXXXXXXXXXXXX' # never the real values
I'm including this mistake on purpose. It's the most useful thing in the whole project: the failure mode for credential leaks isn't malice, it's a convenient "copy" button and not thinking for two seconds.
Mistake #2: HTTP 403, "Just a moment…"
First real run, with cookies loaded and the request built:
403, and a body starting
<!DOCTYPE html>…Just a moment…. That's
Cloudflare's bot-challenge page. So I checked the obvious things:
-
User-Agent? Matched my real Firefox (confirmed with
navigator.userAgentin the console). Not it. -
Cookies present? Wrote a throwaway script to dump
them —
sessionKey,cf_clearance,__cf_bm,lastActiveOrgall there. Not it.
The headers were perfect and Cloudflare still said no. The reason turned out to be a layer below the headers entirely: the TLS handshake itself.
The actual lesson: Cloudflare fingerprints the handshake
When any client opens a TLS connection, the very first message (the ClientHello) advertises which cipher suites it supports, in what order, plus a list of TLS extensions. Different software produces characteristically different ClientHellos — and you can hash that shape into a fingerprint. The common name for this is JA3.
Python's requests talks TLS through OpenSSL, whose
ClientHello looks nothing like Firefox's. So Cloudflare could see —
before a single header was read — that whatever was connecting was
not the browser it claimed to be in its User-Agent. Headers said
"Firefox," the handshake said "Python." Cloudflare believed the handshake.
The fix is a library called curl_cffi: a near drop-in
replacement for requests that runs on libcurl and can
imitate a real browser's TLS fingerprint.
from curl_cffi import requests
requests.get(url, headers=HEADERS, cookies=jar, impersonate="firefox133")
That one impersonate argument reshapes the handshake to look
like Firefox. Same .get() API, same response object —
only the bytes on the wire change. Worked first try.
impersonate
mimics a slightly different one. They don't have to agree — because
they're answering different questions. The header is a claim the
server reads; the fingerprint is evidence from how the connection
was built. Cloudflare cross-checks both, but neither requires the other to
match exactly. Realizing the header and the handshake are separate,
independently-inspected things is the whole point of this post.
And to be clear about what this is: it's a script reading my own account's data, presenting itself honestly as the browser whose session it's already borrowing. It's not an exploit — it's doing the same handshake my browser does. But it taught me that "looks like a browser" is a much deeper question than setting a User-Agent string, which is a genuinely useful thing to understand from both sides.
Making it survive the real world
A script bound to a keyboard shortcut has no terminal to print errors into, and networks fail. So the fetch is wrapped in a stale-while-error pattern: fresh cache wins if it's within its TTL; otherwise try the network; if the network fails, fall back to any cached data — even expired — and label it stale rather than showing nothing.
fresh cache? -> use it
else fetch -> on success, cache it
fetch failed? -> use stale cache, mark "Stale data (N min old)"
no cache at all? -> surface a clear error notification
A transient Cloudflare hiccup leaves me looking at slightly old numbers
with an honest stale marker, not a broken popup. Around that core there's
a config file (so tweaking a threshold isn't a code edit), logging to
~/.cache with crude size-based rotation, and error
notifications that say what to actually do ("Open claude.ai in Firefox").
Things I now know that I didn't
- TLS fingerprinting (JA3) is real, and Cloudflare actually checks it.
- A session cookie is functionally a password — treat it like one.
-
Snap apps store data under
~/snap/<app>/…, not the usual dotfile paths — which is why Ubuntu's snap-Firefox profile isn't where you'd expect. -
GNOME custom shortcuts don't inherit your shell environment, so the
command needs the venv's Python by absolute path, not bare
python3. -
cf_clearanceis bound to your IP and User-Agent — change either and it's invalid even before it expires.
What I'd do differently
Write a five-line spike to confirm the endpoint before designing a
whole script around it. I'd have hit the 403 in the first five minutes and
learned about TLS fingerprinting before writing a hundred lines that
assumed the easy path. And set up .gitignore first, before any
pip install — so the venv never exists in git's eyes,
even for one commit.