Skip to content
Router One
Back to Blog

LLM API Balance Monitoring: Alert Before Credit Runs Out

PublishedByRouter One TeamHow we measure

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:

curl -sS https://api.router.one/v1/balance \
  -H "Authorization: Bearer $ROUTER_ONE_API_KEY"
{
  "object": "balance",
  "currency": "USD",
  "balance": 12.345678,
  "reserved_balance": 0.5,
  "total_balance": 12.845678
}
FieldMeaningUse it for
objectAlways balanceChecking that you parsed the right response
currencyAlways USDLabeling the alert text
balanceSpendable right now, gift credit includedLow-balance alerts
reserved_balanceHeld for in-flight requests; when each request settles, the unused part of the hold returns to balanceExplaining short dips
total_balancebalance + reserved_balanceReconciliation 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.

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).

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.

#!/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.

#!/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. The setup pages: reseller overview, wholesale LLM API, white-label LLM API, and the relay station guide 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, LLM cost tracking.
  • No subscription quota. It reports the prepaid wallet only, not a plan's request quota (/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). 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, 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.

Related canonical pages

This article belongs to the LLM API Gateway and Routing cluster. These pages are the commercial page, setup docs, evidence source, and trust references.

Commercial pageRouter One API gatewayThe product homepage for unified model calls, routing, fallback, budgets, and observability.API docsRouter One API documentationOpenAI-compatible endpoints, CLI setup, and model invocation examples.EvidenceSmart routing methodologyRouting signals, the model and request ID, and the customer-visible trace field boundaries.ComparisonOpenRouter alternativeA professional comparison of global catalog breadth versus China-friendly routing and payments.TrustCiteable factsStable product facts for crawlers, AI answer engines, and customers.Data retentionData retention policyPrompt/completion retention boundaries and request metadata policy.Gateway pageUnified LLM API gatewayOne OpenAI-compatible endpoint for the whole catalog, with routing, fallback, and budgets.Routing pageSmart model routingHow candidate ranking uses latency, posted cost, and reliability signals.Fallback pageLLM provider fallbackWhat makes a request eligible for a retry on another healthy provider route.Observability pagePer-request trace logModel, request ID, tokens, latency, status, and errors for every request.Compatibility pageOpenAI-compatible endpointKeep the OpenAI SDK and change only the base URL to reach every model family.Cost tracking pageLLM cost trackingPer-key, per-model, and per-request spend attribution with hard spend ceilings.Reseller pageBuild your own LLM API serviceSpend-capped customer keys, per-key usage attribution, and an explicit list of what key-level reselling does not give you.Client integrationsSDK and client setup guidesPoint any coding agent, SDK, chat client, or LLM app platform at one endpoint — a dedicated guide for each.Model comparisonsSide-by-side model pricing and contextPer-1M rates, context windows, and capabilities rendered from the live catalog.

Related reads