Billing API
Upthos Relay exposes a small set of read-only /v1/billing/* endpoints for enterprise billing, reconciliation, and cost attribution. All three endpoints share the same auth as the messaging API, and every monetary field is returned as a CNY user-paid amount — drop-in ready for finance pipelines and internal cost dashboards.
When to use this
- Budget monitoring: poll
/balanceon a schedule and alert / auto-recharge whenavailable_balancedrops below a threshold. - Per-call attribution: capture the
x-request-idresponse header on each call, then look up the actual cost through/usageand write it back to your cost-attribution tables. - Multi-team chargeback: aggregate
/usage.costbyapi_key_idto split spend across teams or projects. - Price snapshots: poll
/pricesperiodically and persist theeffective_atsnapshot for after-the-fact reproducibility.
Authentication
Same as /v1/messages. Pass the API key in any one of three headers (priority order shown):
Authorization: Bearer sk-...
x-api-key: sk-...
x-goog-api-key: sk-...
The key must be assigned to a group. None of the billing endpoints care about the platform of that group, but the returned amounts are converted using the key's group rate.
GET /v1/billing/balance
Returns the current key's available balance, key-level quota, and active subscription remaining.
Response
{
"ok": true,
"account_id": 12345,
"key_id": 100,
"group_id": 7,
"currency": "CNY",
"available_balance": 376.55,
"wallet": {
"balance": 1234.56,
"credit_limit": 0
},
"key_quota": {
"limit_cny": 500.00,
"used_cny": 123.45,
"remaining_cny": 376.55
},
"subscription": {
"plan": "codex_standard",
"period_end": "2026-06-01T00:00:00Z",
"monthly_remaining_cny": 800.00
},
"key_status": "active",
"expires_at": "2026-12-31T00:00:00Z",
"updated_at": "2026-05-07T12:00:00Z"
}
In the example above, the key-quota remaining (
376.55 CNY) is tighter than the wallet (1234.56 CNY), soavailable_balance = 376.55. Note thatkey_quota.*fields are reported as raw DB values (apiKey.quotais stored in platform $ units, equivalent 1:1 to CNY); they are not multiplied by the current group rate.
Field reference
| Field | Type | Notes |
|---|---|---|
account_id |
int | Account ID (i.e. users.id) |
key_id |
int | API Key ID |
group_id |
int | null | Group the key belongs to; null when the key has no group |
currency |
string | Always "CNY" |
available_balance |
number | The bottom line. = min(wallet, key_quota_remain, subscription_monthly_remain) in CNY — what the gateway can actually spend. |
wallet.balance |
number | Wallet balance (CNY) |
wallet.credit_limit |
number | Reserved for future credit-line support; currently always 0 |
key_quota |
object | null | Present only when the key has a per-key quota cap configured |
key_quota.limit_cny / used_cny / remaining_cny |
number | Quota cap / spent / remaining. apiKey.quota is stored in platform $ display units (equivalent 1:1 to CNY, same unit as wallet.balance); these three fields are returned as raw DB values with no further conversion |
subscription |
object | null | Present only when the key has an active subscription |
subscription.plan |
string | Plan name (e.g. codex_standard, claude_starter) |
subscription.period_end |
RFC3339 | End of the current billing period |
subscription.monthly_remaining_cny |
number | Remaining monthly cap for the current period. Same platform $ = CNY 1:1 unit; e.g. a Claude Max plan with monthly_limit_usd=2000 yields monthly_remaining_cny capped at 2000 — no extra group-rate multiplication |
key_status |
string | active / disabled / expired |
expires_at |
RFC3339 | null | Key expiry; omitted when the key never expires |
updated_at |
RFC3339 | Time this response was assembled |
How
available_balanceis computed. On every billable call the gateway takesmin(wallet, key_quota_remain, subscription_monthly_remain). If any of the three hits zero, the whole key is blocked — clients only need to readavailable_balanceto know whether the next call will go through.
GET /v1/billing/prices
Returns the CNY user-paid unit price for every model the current key's group is allowed to call.
Response
{
"ok": true,
"currency": "CNY",
"group_id": 7,
"data": [
{
"model": "gpt-5.5",
"provider": "openai",
"group_id": 7,
"currency": "CNY",
"input_price_per_1m_tokens": 1.50,
"output_price_per_1m_tokens": 7.50,
"billing_unit": "token",
"price_available": true
},
{
"model": "claude-sonnet-4-6",
"provider": "anthropic",
"group_id": 7,
"currency": "CNY",
"input_price_per_1m_tokens": 16.50,
"output_price_per_1m_tokens": 82.50,
"cache_read_price_per_1m_tokens": 1.65,
"cache_write_price_per_1m_tokens": 20.625,
"billing_unit": "token",
"price_available": true
}
],
"effective_at": "2026-05-07T12:00:00Z"
}
Field reference
| Field | Type | Notes |
|---|---|---|
currency |
string | Always "CNY" |
group_id |
int | null | Group the current key belongs to |
data[].model |
string | Model ID (matches the model field you pass on /v1/messages etc.) |
data[].provider |
string | Upstream platform: openai / anthropic / google / etc. |
data[].input_price_per_1m_tokens |
number | Input price (CNY per 1M tokens) |
data[].output_price_per_1m_tokens |
number | Output price (CNY per 1M tokens) |
data[].cache_read_price_per_1m_tokens |
number | Prompt-cache hit price; only present when the upstream publishes a non-zero rate |
data[].cache_write_price_per_1m_tokens |
number | Prompt-cache write price; only present when the upstream publishes a non-zero rate |
data[].billing_unit |
string | Currently always "token" |
data[].price_available |
bool | Whether we have a matching price entry. false means the model is callable but has no priced rate yet (typically a freshly-listed model) |
effective_at |
RFC3339 | Snapshot timestamp |
Cache guidance
Prices change infrequently (weekly at most). Clients can do an ETag-style comparison on effective_at and should cache for at least 5 minutes instead of fetching on every call. If your use case is sensitive to price changes (auto-routing, cross-vendor arbitrage), 1 minute is plenty.
We deliberately do not expose
cost_ratio,rate_multiplier, or original USD list prices — only the final CNY user-paid figure.
GET /v1/billing/usage
Returns a cursor-paginated stream of billed calls for the current key, with the per-call CNY cost.
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
start |
YYYY-MM-DD |
7 days ago | Start day, inclusive (covers from 00:00:00Z) |
end |
YYYY-MM-DD |
today | End day, inclusive (covers through 23:59:59Z) |
cursor |
string | empty | Set this to next_cursor from the previous page; omit for the first page |
limit |
int | 100 | Page size; defaults to 100. Values < 1 or non-numeric return 400; values > 1000 are silently clamped to 1000 |
The four invalid-parameter cases all return 400 invalid_request_error:
start/endis not a validYYYY-MM-DDendis beforestartlimitis not a positive integer (less than 1 or non-numeric; values greater than 1000 do not error — they are clamped to 1000)cursoris not one we previously issued (decode failure)
Response
{
"ok": true,
"currency": "CNY",
"data": [
{
"request_id": "req-abc",
"created_at": "2026-05-07T12:00:00Z",
"api_key_id": 100,
"group_id": 7,
"model": "claude-sonnet-4-6",
"requested_model": "claude-sonnet-4-6",
"upstream_model": "claude-sonnet-4-6-20250929",
"input_tokens": 1000,
"output_tokens": 500,
"cost": 0.0578,
"currency": "CNY",
"status": "success",
"duration_ms": 1234
}
],
"next_cursor": "MTcyMjk5..."
}
Field reference
| Field | Type | Notes |
|---|---|---|
request_id |
string | Same value as the x-request-id response header. Use as the join key for reconciliation. |
created_at |
RFC3339 | When the call was made (UTC) |
api_key_id |
int | Key the call was made with |
group_id |
int | null | Group the key was in at call time |
model |
string | Final billed model ID |
requested_model |
string | Model the client originally requested (differs from model when model_mapping rewrote it) |
upstream_model |
string | The exact model the upstream provider served (includes date suffix, e.g. claude-sonnet-4-6-20250929) |
input_tokens / output_tokens |
int | Input / output token counts |
cache_creation_tokens / cache_read_tokens |
int | Prompt-cache write / read tokens; omitted when zero |
cost |
number | CNY user-paid charge for this call, converted using the rate snapshot taken at call time (so historical group-rate changes don't retroactively distort bills). |
currency |
string | Always "CNY" |
status |
string | Always "success" for now (usage_logs only stores successful billed calls; errors live separately in ops_error_logs) |
duration_ms |
int | Upstream response latency in milliseconds; omitted when not measured |
next_cursor |
string | Cursor for the next page. Empty / missing means you've reached the end. |
Cursor pagination
# First page
curl 'https://relay.upthos.com/v1/billing/usage?start=2026-05-01&end=2026-05-07&limit=200' \
-H "Authorization: Bearer $UPTHOS_API_KEY"
# Subsequent pages: pass the previous response's next_cursor verbatim
curl 'https://relay.upthos.com/v1/billing/usage?start=2026-05-01&end=2026-05-07&limit=200&cursor=MTcyMjk5...' \
-H "Authorization: Bearer $UPTHOS_API_KEY"
Stop when next_cursor is empty or missing.
Response headers
Every /v1 endpoint (not just billing) emits the following headers — capture whichever ones are useful for your reconciliation pipeline:
| Header | Meaning |
|---|---|
x-request-id |
UUID per call. The reconciliation key, identical to /billing/usage.data[].request_id. If the client supplies an x-request-id request header, the gateway echoes it through verbatim. |
x-upthos-key-id |
Current API Key's id (decimal) |
x-upthos-group-id |
Group id of the current key (decimal); omitted when the key has no group |
x-upthos-billing-currency |
Always CNY; documents the currency convention used by the billing endpoints |
Field unit conventions
- All monetary fields (
available_balance,wallet.balance,key_quota.limit_cny,subscription.monthly_remaining_cny,cost, etc.) are CNY user-paid. - Token counts (
input_tokens,output_tokens,cache_*_tokens) are raw integers — no unit conversion. - All timestamp fields are RFC3339 / ISO 8601 in UTC (suffix
Z).
All three balance dimensions share the same billing unit.
wallet.balance,key_quota.*, andsubscription.monthly_remaining_cnyuse the same unit — CNY user-paid (i.e. platform $ display, equivalent 1:1 to CNY). The prices endpoint, by contrast, reports CNY unit prices converted from upstream USD list prices (×group_rate). When a single call consumes ¥X worth of tokens, the wallet/quota/sub remaining all decrement by exactly X, closing the loop.About the CNY ↔ USD conversion. Our cost layer uses an implicit
$1 ≈ ¥1conversion (then multiplied by the current grouprate_multiplier). The Stripedisplay_fx_rate(≈6.82) is only used at payment time to credit USD card payments into the CNY wallet — it does not participate in cost deduction or in the conversions surfaced by this API./v1/billing/pricesunit prices = upstream USD price × group rate;/v1/billing/balancewallet/quota/sub are already in platform $ = CNY units, so no further conversion is applied.
Error codes
Errors follow the Anthropic-style envelope:
{
"type": "error",
"error": {
"type": "authentication_error",
"message": "Invalid API key"
}
}
| HTTP | error.type |
Meaning |
|---|---|---|
| 401 | authentication_error |
Missing or invalid API key |
| 403 | permission_error |
Key has no group, is disabled, or lacks permission |
| 400 | invalid_request_error |
Bad request parameters (bad date / bad limit / bad cursor / end < start) |
| 404 | not_found_error |
Path or resource does not exist |
| 429 | rate_limit_error |
Rate / concurrency limit exceeded |
| 500 | api_error |
Internal gateway error |
Reconciliation patterns
Pattern 1: Look up a single call's cost by request_id
Capture x-request-id on every call. When you need the actual cost later, query /usage for the relevant time window and join on request_id:
import os, requests
BASE = "https://relay.upthos.com"
HEADERS = {"Authorization": f"Bearer {os.environ['UPTHOS_API_KEY']}"}
# 1) Make the business call, record x-request-id
r = requests.post(
f"{BASE}/v1/messages",
headers={**HEADERS, "anthropic-version": "2023-06-01"},
json={
"model": "claude-sonnet-4-6",
"max_tokens": 256,
"messages": [{"role": "user", "content": "hi"}],
},
)
req_id = r.headers["x-request-id"]
log_to_business_db(req_id=req_id, biz_action="some-feature")
# 2) Look up the cost later (next minute, next day, doesn't matter)
def find_cost(req_id, day):
cursor = ""
while True:
params = {"start": day, "end": day, "limit": 1000}
if cursor:
params["cursor"] = cursor
resp = requests.get(f"{BASE}/v1/billing/usage", headers=HEADERS, params=params).json()
for row in resp["data"]:
if row["request_id"] == req_id:
return row["cost"] # CNY
cursor = resp.get("next_cursor", "")
if not cursor:
return None
Pattern 2: Daily reconciliation + per-team chargeback
Pull yesterday's full ledger every night and aggregate by api_key_id:
#!/usr/bin/env bash
# nightly-reconcile.sh — runs at 02:00 daily
set -euo pipefail
YESTERDAY=$(date -u -d 'yesterday' +%F) # macOS: $(date -u -v-1d +%F)
CURSOR=""
while :; do
if [[ -n "$CURSOR" ]]; then
URL="https://relay.upthos.com/v1/billing/usage?start=${YESTERDAY}&end=${YESTERDAY}&limit=1000&cursor=${CURSOR}"
else
URL="https://relay.upthos.com/v1/billing/usage?start=${YESTERDAY}&end=${YESTERDAY}&limit=1000"
fi
RESP=$(curl -sS "$URL" -H "Authorization: Bearer $UPTHOS_API_KEY")
echo "$RESP" | jq -c '.data[] | {request_id, api_key_id, model, cost, created_at}' \
>> "/var/log/upthos/usage-${YESTERDAY}.jsonl"
CURSOR=$(echo "$RESP" | jq -r '.next_cursor // ""')
[[ -z "$CURSOR" ]] && break
done
# Load + aggregate into the internal cost-attribution table
psql "$DSN" -c "
COPY staging_usage(request_id, api_key_id, model, cost, created_at)
FROM '/var/log/upthos/usage-${YESTERDAY}.jsonl' (FORMAT json);
INSERT INTO daily_cost_attribution(day, api_key_id, total_cny)
SELECT '${YESTERDAY}'::date, api_key_id, SUM(cost)
FROM staging_usage
WHERE created_at::date = '${YESTERDAY}'::date
GROUP BY api_key_id
ON CONFLICT (day, api_key_id) DO UPDATE SET total_cny = EXCLUDED.total_cny;
"
Idempotency tip:
request_idis globally unique. Make it the primary key on your usage-detail table so re-runs of the script never double-count.