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 /balance on a schedule and alert / auto-recharge when available_balance drops below a threshold.
  • Per-call attribution: capture the x-request-id response header on each call, then look up the actual cost through /usage and write it back to your cost-attribution tables.
  • Multi-team chargeback: aggregate /usage.cost by api_key_id to split spend across teams or projects.
  • Price snapshots: poll /prices periodically and persist the effective_at snapshot 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), so available_balance = 376.55. Note that key_quota.* fields are reported as raw DB values (apiKey.quota is 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_balance is computed. On every billable call the gateway takes min(wallet, key_quota_remain, subscription_monthly_remain). If any of the three hits zero, the whole key is blocked — clients only need to read available_balance to 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 / end is not a valid YYYY-MM-DD
  • end is before start
  • limit is not a positive integer (less than 1 or non-numeric; values greater than 1000 do not error — they are clamped to 1000)
  • cursor is 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.*, and subscription.monthly_remaining_cny use 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 ≈ ¥1 conversion (then multiplied by the current group rate_multiplier). The Stripe display_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/prices unit prices = upstream USD price × group rate; /v1/billing/balance wallet/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_id is globally unique. Make it the primary key on your usage-detail table so re-runs of the script never double-count.