Monitor balance and usage
Your balance is held on chain and debited request by request. It is a cliff, not a slope: while there are funds every request is served, and at zero every request returns 402. Nothing warns you on the way down unless you build the warning. This page covers the account endpoints, an alert you can put on a schedule, and what to do when the 402 arrives anyway.
Prerequisites
Section titled “Prerequisites”- An account with a funded balance. See Get 0G and fund your account.
- An
mk-management key with theaccount:readscope, exported asZG_MANAGEMENT_KEY.
Account endpoints need a management key
Section titled “Account endpoints need a management key”This is the first thing that trips people up, so it comes before the code.
/v1/account/* accepts only an mk- management key carrying the account:read scope. An sk- inference key returns 403 insufficient_scope, always — not because the scope is missing but because inference credentials cannot read account state at all. Two credentials, two jobs:
| Prefix | Used for | Reaches /v1/account/* |
|---|---|---|
sk- |
Inference requests, billed against your balance | No — 403 insufficient_scope |
mk- with account:read |
Reading balance and usage | Yes |
Keep them in separate environment variables. ZG_API_KEY goes to the runtime that serves traffic; ZG_MANAGEMENT_KEY goes only to the job that reads your finances. Grant the key account:read and nothing more — a balance alert has no reason to be able to create keys. See API keys for creating and scoping both kinds.
Read balance and usage
Section titled “Read balance and usage”# Spendable balance. ZG_MANAGEMENT_KEY holds an mk- key with account:read.curl "$ZG_BASE_URL/account/balance" \ --max-time 30 \ --fail-with-body \ --silent \ -H "Authorization: Bearer $ZG_MANAGEMENT_KEY"# Usage over a window. Omit end_date to leave the window open.curl "$ZG_BASE_URL/account/usage/stats?start_date=2026-07-01&end_date=2026-07-20" \ --max-time 30 \ --fail-with-body \ --silent \ -H "Authorization: Bearer $ZG_MANAGEMENT_KEY"// These are not OpenAI-compatible endpoints; use plain fetch.const baseUrl = process.env.ZG_BASE_URL ?? "https://router-api.0g.ai/v1";const managementKey = process.env.ZG_MANAGEMENT_KEY;if (!managementKey) throw new Error("ZG_MANAGEMENT_KEY is not set");
async function accountGet<T>(path: string): Promise<T> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30_000); try { const res = await fetch(`${baseUrl}${path}`, { signal: controller.signal, headers: { Authorization: `Bearer ${managementKey}` }, }); if (!res.ok) { const body = await res.text(); // 403 insufficient_scope here almost always means an sk- key was used. throw new Error(`GET ${path} failed: ${res.status} ${body}`); } return (await res.json()) as T; } finally { clearTimeout(timer); }}
const balance = await accountGet<Record<string, unknown>>("/account/balance");const usage = await accountGet<Record<string, unknown>>( "/account/usage/stats?start_date=2026-07-01&end_date=2026-07-20",);
console.log(JSON.stringify({ event: "account_poll", balance, usage }));import jsonimport os
import httpx
BASE_URL = os.environ.get("ZG_BASE_URL", "https://router-api.0g.ai/v1")HEADERS = {"Authorization": f"Bearer {os.environ['ZG_MANAGEMENT_KEY']}"}
def account_get(path: str, params: dict | None = None) -> dict: with httpx.Client(timeout=30.0) as client: res = client.get(f"{BASE_URL}{path}", headers=HEADERS, params=params) if res.status_code == 403: # Almost always an sk- key, or an mk- key without account:read. raise RuntimeError(f"insufficient scope for {path}: {res.text}") res.raise_for_status() return res.json()
balance = account_get("/account/balance")usage = account_get( "/account/usage/stats", params={"start_date": "2026-07-01", "end_date": "2026-07-20"},)
print(json.dumps({"event": "account_poll", "balance": balance, "usage": usage}))GET /v1/account/funds is a third endpoint on the same scope. Where balance answers “how much can I spend”, funds shows where that money sits:
{ "address": "0xcc539270e65f88c2b3798c4e827610cf1fb89574", "currency": "0g", "total": "97149123997100000000", "router": { "deposit": "0", "credit": "97149123997100000000", "pending_charge": "0", "subtotal": "97149123997100000000" }, "payment_layer": { "balance": "0", "shared_across_products": true, "queried_at": "2026-07-20T08:24:55Z" }}Live response, 2026-07-20. router is the balance this API spends from, split into what you deposited directly and any credit. payment_layer reports a balance held at the account level that is shared across 0G products rather than reserved for inference. Both are reported so that a zero router balance next to a non-zero payment_layer balance is diagnosable rather than mysterious.
Auditing which tier served your traffic
Section titled “Auditing which tier served your traffic”usage/stats returns account totals — requests, tokens, and cost — and, as of 2026-07-20, no breakdown by trust tier. If you need to show how much traffic ran sealed, record it yourself: every response carries the provider address in x_0g_trace, and the tier a provider can serve is in the catalog’s verifiability field. Logging provider per request (see Track what each request costs) gives you evidence from the response rather than an assertion about your own configuration.
Prompts and completions are never stored, so anything you cannot reconstruct from your own logs is not recoverable later. See Trust modes for what each tier guarantees.
Alert before you run dry
Section titled “Alert before you run dry”Poll on a schedule — every few minutes is plenty. This is not an inference endpoint and it does not belong in the request path.
#!/bin/sh# Balance alert. Exits non-zero when the balance is at or below the threshold,# so cron or a monitoring agent can page on it.set -eu
: "${ZG_MANAGEMENT_KEY:?ZG_MANAGEMENT_KEY is not set}"BASE_URL="${ZG_BASE_URL:-https://router-api.0g.ai/v1}"THRESHOLD="${ZG_BALANCE_THRESHOLD:?set a threshold in the same unit the API returns}"
response=$(curl "$BASE_URL/account/balance" \ --max-time 30 \ --fail-with-body \ --silent \ -H "Authorization: Bearer $ZG_MANAGEMENT_KEY")
balance=$(printf '%s' "$response" | jq -r '.total_balance')
# Balances are returned as strings and can exceed shell integer range,# so compare as floating point rather than with test -lt.if awk -v b="$balance" -v t="$THRESHOLD" 'BEGIN { exit !(b <= t) }'; then printf 'router balance %s at or below threshold %s\n' "$balance" "$THRESHOLD" >&2 exit 1fiThe field to read is balance (total_balance carries the same value), and like every amount in this API it is an integer string in the smallest unit of 0G — 1018 per 0G. A threshold of “one 0G” is therefore 1000000000000000000, not 1.
Two notes on picking the threshold:
- Alert on runway, not on a fixed number. Divide the current balance by recent burn from
usage/stats, and page when the remaining time drops below however long it takes a human to fund the account — hours, not minutes. A fixed number stops meaning anything the moment traffic doubles. - Alert separately on zero. A traffic spike can outrun any runway estimate mid-incident.
Handling 402 insufficient_balance
Section titled “Handling 402 insufficient_balance”{ "error": { "message": "Insufficient balance to process request", "type": "payment_error", "code": "insufficient_balance" }, "request_id": "<REQUEST_ID>"}The envelope is verified against the live API; the 402 body itself is reproduced from the error contract rather than triggered against a funded account.
Do not retry it. No amount of backoff creates funds, and a retry loop converts a funding problem into a funding problem plus a rate-limit problem. Fail the request, page whoever can fund the account, and deposit — see Get 0G and fund your account. Funds are spendable within a few seconds of the deposit transaction confirming.
The same numbers in the Console
Section titled “The same numbers in the Console”Everything above is visible at pc.0g.ai if you would rather look than poll:
| Dashboard tab | Shows |
|---|---|
| Overview | Current balance, spend and request count for the period, active keys, recent requests |
| Activity | Full request history by model, time, and cost |
| Billing | Current balance and the funding entry |
The Console is the right surface for a spot check or for the price of a model. The endpoints are the right surface for anything that needs to page someone at 3am.
Expected output
Section titled “Expected output”GET /v1/account/balance:
{ "address": "0xcc539270e65f88c2b3798c4e827610cf1fb89574", "currency": "0g", "deposit_balance": "0", "credit_balance": "97163900867100000000", "pending_charge": "0", "total_balance": "97163900867100000000", "balance": "97163900867100000000"}GET /v1/account/usage/stats:
{ "total_requests": 63, "total_tokens": 1438636, "total_cost": "2850876002900000000", "prompt_tokens": 1420254, "completion_tokens": 18382, "currency": "0g"}Live responses, 2026-07-20. total_cost is an integer string in the smallest unit of 0G: 2850876002900000000 is 2.8509 0G.
Common errors
Section titled “Common errors”| Status | code |
Cause | Fix |
|---|---|---|---|
| 401 | missing_authorization |
No Authorization header |
Send Bearer mk-... |
| 401 | invalid_api_key |
The management key is wrong or was revoked | Issue a replacement in the Console |
| 403 | insufficient_scope |
An sk- key was used, or the mk- key lacks account:read |
Use an mk- key with account:read |
| 402 | insufficient_balance |
Balance exhausted on an inference call | Fund the account; do not retry |
| 429 | rate_limit_exceeded |
Polling too aggressively | Honour Retry-After; poll on a schedule, not per request |
Full list with retry guidance: Error codes.
Next steps
Section titled “Next steps”- Harden for production — where balance alerting sits in the wider pre-launch checklist
- Track what each request costs — the per-request record these account totals reconcile against