> Markdown mirror of https://router.one/blog/llm-api-balance-monitoring for AI assistants and crawlers. Router One is an OpenAI-compatible LLM API gateway.
> Published: 2026-09-19 · Author: Router One Team

# LLM API Balance Monitoring: Alert Before Credit Runs Out

_Monitor a prepaid LLM API balance from a server: GET /v1/balance fields, a burn-rate threshold, debounced alerts, and bash and Python monitors for resellers._

`GET /v1/balance` returns the prepaid balance of the account behind a Router One API key, in USD, so a server can watch the wallet instead of a person watching the dashboard. The response carries three numbers: `balance` is what you can spend right now (gift credit included), `reserved_balance` is what the gateway is holding for requests still in flight, and `total_balance` is the two added together. Alert on `balance`; reconcile with `total_balance`. Below: a threshold taken from your own burn rate, a debounce for concurrency dips, two monitors you can paste, and why the balance button in one-api / new-api is not the tool for this job.

## The request and the response

One GET, no body, no query parameters. Any normal API key of the account works, sent the same way as on a model call:

```bash
curl -sS https://api.router.one/v1/balance \
  -H "Authorization: Bearer $ROUTER_ONE_API_KEY"
```

```json
{
  "object": "balance",
  "currency": "USD",
  "balance": 12.345678,
  "reserved_balance": 0.5,
  "total_balance": 12.845678
}
```

| Field | Meaning | Use it for |
| --- | --- | --- |
| `object` | Always `balance` | Checking that you parsed the right response |
| `currency` | Always `USD` | Labeling the alert text |
| `balance` | Spendable right now, gift credit included | Low-balance alerts |
| `reserved_balance` | Held for in-flight requests; when each request settles, the unused part of the hold returns to `balance` | Explaining short dips |
| `total_balance` | `balance` + `reserved_balance` | Reconciliation and the slow trend |

Three properties matter for a monitor. The number belongs to the **account**, not the key: the answer is the same whichever key asks. The response header is `Cache-Control: private, no-store`, so every poll is a live value. And the endpoint has its own rate limit of 60 requests per minute per key, in a bucket separate from the key's inference rate limit: polling never uses production's allowance, and busy traffic never starves the monitor. Reference: [GET /v1/balance](https://router.one/docs/account/getBalance).

## Pick the threshold from your burn rate

A fixed number such as "alert under $10" is weeks of runway for a side project and minutes for a relay station at peak. Derive it instead:

1. Read your recent spend in the Dashboard usage view. Take a busy day, not an average one, and turn it into dollars per hour.
2. Decide how many hours a person needs to see the alert and top up. With nights and weekends, that is usually 12 to 48 hours, not one.
3. Threshold = hourly spend × those hours. About $3 an hour at peak with a 24-hour reaction window means alerting under $72.
4. Recompute when traffic grows. A threshold set in a quiet month is too low by the time it matters.

## Why balance dips, and how to debounce it

While a request is in flight the gateway holds an estimated cost in `reserved_balance`; when it settles, the unused part returns to `balance`. Under concurrency many holds are open at once, so `balance` can sit below its settled value for a moment while `total_balance` stays steady. A monitor that fires on one low reading pages you for traffic, not for money. Two fixes, and they combine:

- **Require consecutive low polls.** Alert only after `balance` has been under the threshold for 2–3 polls in a row. A hold clears when its request settles; a wallet that is running out stays low.
- **Split the signal.** Compare `total_balance` with the threshold for the slow "top up today" warning, because holds do not move it, and `balance` with a much smaller floor for the urgent one. The urgent one is what breaks traffic: a request whose initial hold the spendable funds cannot cover fails with HTTP 402 even when `total_balance` is higher ([402 diagnostics](https://router.one/llm-api-error-codes)).

Poll every 1–5 minutes. The limit allows far more, but a balance does not move fast enough to need it: with a three-poll debounce, a 2-minute interval confirms a real shortfall in about six minutes.

## Monitor 1: bash, curl and jq from cron

The key and the webhook URL stay in environment variables. The webhook receives a generic `{"text": "..."}` JSON body; change the shape to whatever your chat or paging tool expects.

```bash
#!/usr/bin/env bash
# balance-check.sh: run from cron, for example every 5 minutes:
# */5 * * * * . /etc/router-one-monitor.env && /usr/local/bin/balance-check.sh
# (the env file holds the two export lines and is chmod 600)
set -euo pipefail

: "${ROUTER_ONE_API_KEY:?set ROUTER_ONE_API_KEY}"
: "${ALERT_WEBHOOK_URL:?set ALERT_WEBHOOK_URL}"
THRESHOLD_USD="${THRESHOLD_USD:-25}"   # your number: hourly spend x hours of runway
STRIKES_NEEDED="${STRIKES_NEEDED:-3}"  # consecutive low polls before alerting
STATE_FILE="${STATE_FILE:-/var/tmp/router-one-balance.strikes}"

body=$(curl -fsS --max-time 10 \
  -H "Authorization: Bearer $ROUTER_ONE_API_KEY" \
  https://api.router.one/v1/balance)
balance=$(jq -er '.balance' <<<"$body")
total=$(jq -er '.total_balance' <<<"$body")

if jq -en --argjson b "$balance" --argjson t "$THRESHOLD_USD" '$b < $t' >/dev/null; then
  strikes=$(( $(cat "$STATE_FILE" 2>/dev/null || echo 0) + 1 ))
else
  strikes=0
fi
echo "$strikes" > "$STATE_FILE"

# Fire once per low episode; the counter re-arms after a top-up.
if [ "$strikes" -eq "$STRIKES_NEEDED" ]; then
  jq -n --arg text "Router One balance low: $balance USD spendable, $total USD total (threshold $THRESHOLD_USD USD)" '{text: $text}' |
    curl -fsS --max-time 10 -H "Content-Type: application/json" -d @- "$ALERT_WEBHOOK_URL" >/dev/null
fi
```

`curl -f` makes a 401, a 429 or an outage exit non-zero, so cron's failure reporting sees a monitor that has gone blind. The alert fires once when the streak reaches the limit, not every five minutes until someone tops up.

## Monitor 2: a Python poller with backoff

Standard library only. It retries 429, 5xx and network errors with exponential backoff, does not retry a 401, and sends its own alert after three failed cycles.

```python
#!/usr/bin/env python3
"""Poll GET /v1/balance; post to a webhook when the balance stays low."""
import json, os, time, urllib.error, urllib.request

API_KEY = os.environ["ROUTER_ONE_API_KEY"]
WEBHOOK = os.environ["ALERT_WEBHOOK_URL"]
THRESHOLD = float(os.environ.get("THRESHOLD_USD", "25"))
INTERVAL = float(os.environ.get("POLL_SECONDS", "120"))  # 1-5 minutes is plenty
STRIKES_NEEDED = 3  # consecutive low polls before alerting

def get_balance():
    req = urllib.request.Request(
        "https://api.router.one/v1/balance",
        headers={"Authorization": f"Bearer {API_KEY}"},
    )
    delay = 2
    for _ in range(6):
        try:
            with urllib.request.urlopen(req, timeout=10) as res:
                return json.load(res)
        except urllib.error.HTTPError as err:
            if err.code != 429 and err.code < 500:
                raise  # 401 and other 4xx: fix the key, retrying will not help
        except urllib.error.URLError:
            pass  # network blip: retry
        time.sleep(delay)  # exponential backoff: 2, 4, 8, 16, 32 s
        delay = min(delay * 2, 60)
    raise RuntimeError("no answer from /v1/balance after 6 attempts")

def notify(text):
    data = json.dumps({"text": text}).encode()
    req = urllib.request.Request(WEBHOOK, data=data, headers={"Content-Type": "application/json"})
    urllib.request.urlopen(req, timeout=10).close()

strikes = failures = 0
while True:
    try:
        b = get_balance()
        failures = 0
        strikes = strikes + 1 if b["balance"] < THRESHOLD else 0
        if strikes == STRIKES_NEEDED:  # fire once per low episode
            notify(f"Router One balance low: {b['balance']:.2f} USD spendable, "
                   f"{b['total_balance']:.2f} USD total (threshold {THRESHOLD:.2f} USD)")
    except Exception as exc:  # a blind monitor is an incident too
        failures += 1
        if failures == 3:
            notify(f"Router One balance check failing: {exc}")
    time.sleep(INTERVAL)
```

Run it under whatever restarts your other daemons. A failed check means "unknown", never "zero".

## Resellers and relay stations: what this number protects

Two limits are in play when you resell access or run a relay station on one account, and they answer different questions.

**Per-customer limits are the keys' maxSpend caps.** The wallet is shared by every key on the account, so `/v1/balance` cannot say what one customer has left. That job belongs to the key: one key per customer or group, each with its own `maxSpend`; a customer who reaches the cap gets HTTP 402 on that key while the wallet and the other keys are untouched. The pattern: [reselling with spend-capped keys](https://router.one/blog/resell-llm-api-spend-capped-keys). The setup pages: [reseller overview](https://router.one/llm-api-reseller), [wholesale LLM API](https://router.one/wholesale-llm-api), [white-label LLM API](https://router.one/white-label-llm-api), and the [relay station guide](https://router.one/llm-api-relay-station) for one-api / new-api.

**/v1/balance protects the whole business.** When the wallet itself cannot cover a request, model calls fail with HTTP 402 on **every** key at once, whichever customer sent them and however much room their own cap has left. To your customers that is a full outage, and no per-key cap warns you about it, because no single key did anything unusual. That is the event to alert on, and the threshold above is sized for it: the sum of all customers' traffic.

**Use a dedicated monitoring key.** The monitor never makes a model call, so its key needs no spend: give it a tiny `maxSpend`. If it leaks it cannot run up usage, and rotating it never touches a customer key or the relay's channel key.

**The relay's built-in balance button does not cover this.** one-api (click a channel's balance cell) and new-api (the "Update Balance" / 更新余额 button) both run the lookup in `controller/channel-billing.go`. For a channel of type OpenAI (and the Custom type) it sends two GET requests to the channel's base URL, `/v1/dashboard/billing/subscription` and then `/v1/dashboard/billing/usage`, and computes the balance as `hard_limit_usd` minus `total_usage / 100`. Those are OpenAI's legacy billing paths; one-api's own channel page already notes that OpenAI channels no longer support getting the balance with a key. Router One does not implement them, nor `/dashboard/billing/credit_grants`, so the button cannot read this wallet: a non-200 answer becomes the error text `status code: <n>`, and the channel's stored balance is left unchanged. Channel types without a lookup of their own return 「尚未实现」 (not implemented), which in one-api includes its separate OpenAI-compatible type. new-api's scheduled refresh (`CHANNEL_UPDATE_FREQUENCY`) runs the same lookup and skips a channel whose lookup errors, so it does not alert either. On new-api's main branch as of 2026-09-19, the Advanced Custom channel type takes a balance route you configure, but it stores a number only for a `credit_summary` object with a numeric `total_available`; other JSON, the `balance` object above included, is displayed under "Balance response not recognized" and not saved. So run one of the two monitors next to the relay, on its own key.

## What the endpoint does not do, and its errors

- **No per-key spend.** It does not report how much of a key's `maxSpend` is used. Per-key spend is in Dashboard → Logs and the usage views: [track LLM API costs per key](https://router.one/blog/track-llm-api-costs-per-key), [LLM cost tracking](https://router.one/llm-cost-tracking).
- **No subscription quota.** It reports the prepaid wallet only, not a plan's request quota ([/pricing](https://router.one/pricing)).
- **No top-up API.** The account owner tops up in the dashboard, with a card or Alipay through one hosted checkout, or with USDT/USDC. No endpoint adds funds and nothing recharges on its own; the alert's job is to reach a person in time.
- **No history.** One live value per call. For a balance chart, store your own polls; for what was spent and by which key, reconcile in Dashboard → Logs and usage.

Errors use the OpenAI-style body `{"error":{"message","type","code","request_id"}}`; keep the `request_id` for support. A missing or invalid key returns 401 `AUTH_INVALID_API_KEY` (`authentication_error`): do not retry, fix the key. More than 60 requests a minute on one key returns 429 `RATE_LIMIT_EXCEEDED` (`rate_limit_error`): back off, retry, and check whether several monitors share a key. On model calls, money problems return 402 and speed problems return 429, unlike the official OpenAI API, where an exhausted account arrives as a 429 `insufficient_quota` ([comparison](https://router.one/openai-insufficient-quota)). Back off on 429; never retry a 402, top up.

To set it up: create a dedicated key in Dashboard → API Keys at [router.one](https://router.one/), give it a tiny `maxSpend`, export it together with your webhook URL, and schedule one of the two monitors.

## FAQ

**Which field should a low-balance alert use?**
balance. It is the amount you can spend right now, gift credit included. total_balance adds the funds held for in-flight requests, so it is the steadier number for reconciliation, but a request fails with 402 when the spendable amount cannot cover its initial hold.

**How often can I poll /v1/balance, and does polling use my inference rate limit?**
The endpoint allows 60 requests per minute per key in its own bucket, so polling does not consume the key's inference rate limit. Every 1–5 minutes is plenty. Past the limit it returns 429 RATE_LIMIT_EXCEEDED; back off and retry.

**Does each key or each customer have its own balance?**
No. The balance is the account's prepaid wallet, and every key on the account returns the same number. Per-customer limits are the maxSpend caps on each key, and per-key spend is in Dashboard → Logs. /v1/balance guards against the shared wallet emptying, when every key returns 402.

**Does the one-api or new-api balance button work with a Router One channel?**
Not for OpenAI-type or Custom channels. The button requests /v1/dashboard/billing/subscription and /v1/dashboard/billing/usage, OpenAI's legacy billing paths, which Router One does not implement, so the lookup ends in a status code error and the stored channel balance does not change. Poll /v1/balance from a small cron job instead.

## See also

- Canonical page: https://router.one/blog/llm-api-balance-monitoring
- LLM API Gateway and Routing: https://router.one/llm-api-gateway
- All blog posts: https://router.one/blog
- Models and per-model token rates: https://router.one/models (markdown: https://router.one/models.md)
- Pricing: https://router.one/pricing
- API docs (markdown): https://router.one/docs.md
- Company facts: https://router.one/facts/company.md
