← Back to lab

Key Limit Exceeded Is Not Out of Credits: 3 Per-Key Caps Hit Zero While the Account Held $28

Three OpenRouter keys were capped out while the account still had $28 left. Why the error reads like an empty balance, and the two calls that tell them apart.

Key Limit Exceeded Is Not Out of Credits: 3 Per-Key Caps Hit Zero While the Account Held $28
Account: $595 bought / $567 used  |  Keys capped: 3 of 4  |  Date: 2026-09/10  |  Status: diagnosed

Three of my four OpenRouter keys were returning Key limit exceeded. The account balance was $28.

"Key limit exceeded" reads like "out of credits". It is not. It is a per-key spend cap, and it fires independently of the account balance.

What the state looked like

Each project has its own key with its own limit. That is deliberate: one runaway job can only burn its own budget.

KeyLimitUsedState
content project A$30$30capped
backend project B$40$40capped
SEO project C$10$10capped
content project D$10$5healthy
Account$595 bought$567 used$28 left

Three keys at exactly 100% of their limit. The account itself was fine. Topping up the account balance would have fixed nothing, because the cap sits on the key.

Why it misleads

The failure looks the same to a retry wrapper whether the account is empty or one key is capped. The account balance, the number everyone watches, said $28. Three pipelines were failing anyway.

Two failure classes, same symptom:

  • Account empty: fix is a payment. Every key dies at once.
  • Key capped: fix is raising that one key's limit. Other keys keep working.

The second one is partial: other keys keep working, so nothing looks like a full outage.

The two calls that separate them

# Per-key: limit, usage, remaining for the key you pass
curl -s https://openrouter.ai/api/v1/key \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" | jq '.data | {limit, usage, limit_remaining}'

# Account-wide: total bought vs total used
curl -s https://openrouter.ai/api/v1/credits \
  -H "Authorization: Bearer $OPENROUTER_MGMT_KEY" | jq '.data | .total_credits - .total_usage'

Run the first one once per project key. limit_remaining: 0 with a healthy second call is the whole diagnosis. Print both in one table and a capped key shows up as a row, not as a mystery.

The same class of bug, one layer over

The scheduler that runs my agent sessions fails over between two cheap providers. Both ran out of quota within the same stretch. The failover logic only recognised certain exhaustion messages. One provider answered with a 403 saying a weekly usage limit was reached. The other included a reset timestamp in a bracketed error code. Neither matched the patterns, so the code treated them as ordinary failures instead of quota exhaustion.

Result: 114 of 114 scheduled sessions failed, each one retried against a provider that was never going to answer until the reset.

Quota errors are a distinct class. They need their own branch: do not retry, mark the provider exhausted until the reset time, move on. Matching on message text is fragile, because every provider words it differently and changes it without notice.

Budget numbers worth knowing

Burn rate on the account was about $8 per week. Balance by date:

DateRemaining
Oct 4~$17
Oct 11$9.17
Oct 18$17.93 (after a $10 top-up)

At $8/week, $9 is a bit over a week of runway. Per-key caps hit first because they were sized per project, not per account.

What I'd change

Per-key caps are the right design, and I would keep them. I would stop treating them as invisible. Each key gets a daily limit_remaining reading logged next to the account balance, with an alert at 20% remaining per key. And quota detection moves from string matching to status plus a provider-specific parser, with a default of "unknown error from a provider that previously worked means assume exhausted, back off".