Pulling billing history via GET /v1/usage for your own reports

When you need to know exactly what each client or project cost, clicking through a dashboard does not scale. Here is how to pull the full history through the API.

5 min · · Updated · API · development

When traffic from several clients or projects goes through a single key, sooner or later you need to know who spent what. The dashboard shows balance and recent transactions, but that is not enough for an end-of-month report — you need a full export broken down by model and date. That is what GET /v1/usage is for. One caveat up front: its history covers the whole account (all keys together) with no per-key filter, so a breakdown by client or project has to be kept on your side — more on that below.

What the endpoint returns

GET /v1/usage is the billing history with cursor-based pagination. It requires a key in the Authorization: Bearer fetch_... header. Each record corresponds to one paid request to a model and carries the fields created_at, model, input_tokens, output_tokens, total_tokens, and cost_cents (cost in fractional cents); the cursor for the next page comes in next_cursor. Response-cache hits and balance top-ups are not written to the history. Failed requests are not billed, because the balance check happens before the provider is contacted, so you will not see entries for 401/402/404 errors in the history.

Basic request:

bash
curl https://api.zukko.pro/v1/usage \
  -H "Authorization: Bearer fetch_your_key"

Cursor pagination means the server hands you a pointer to the next chunk instead of page numbers. The logic is standard for this approach: fetch the first batch, take the cursor for continuation from the response, pass it into the next request, and repeat until the cursor runs out.

bash
curl "https://api.zukko.pro/v1/usage?cursor=NEXT_CURSOR" \
  -H "Authorization: Bearer fetch_your_key"

A script for a full export

A report usually needs the whole period at once, not one page. A Python script that walks every page and collects records into a list:

bash
import requests

API_KEY = "fetch_your_key"
BASE = "https://api.zukko.pro/v1/usage"

records = []
cursor = None

while True:
    params = {"cursor": cursor} if cursor else {}
    resp = requests.get(BASE, headers={"Authorization": f"Bearer {API_KEY}"}, params=params)
    resp.raise_for_status()
    data = resp.json()

    records.extend(data.get("data", []))
    cursor = data.get("next_cursor")

    if not cursor:
        break

print(f"Total records: {len(records)}")

If you have several keys — say, one per client — running the script per key gets you nothing: the history is account-wide, and any key returns the same records for all keys. To split spend by client or project, log X-Zukko-Cost-Micros from responses per key on your side (for non-streaming requests), or set up a separate account per client. The balance is one too — per account, not per key.

If your stack is Node, not Python

The same pagination logic in JavaScript, no extra libraries needed:

bash
const API_KEY = "fetch_your_key";
const BASE = "https://api.zukko.pro/v1/usage";

async function fetchAllUsage() {
  const records = [];
  let cursor = null;

  while (true) {
    const url = cursor ? `${BASE}?cursor=${cursor}` : BASE;
    const resp = await fetch(url, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });
    const data = await resp.json();

    records.push(...(data.data || []));
    cursor = data.next_cursor;

    if (!cursor) break;
  }

  return records;
}

The logic is identical to the Python version: keep pulling pages while the server returns a next_cursor, and stop as soon as it comes back empty. If the report runs in CI or in a separate Node worker, you can call this function once a day on a schedule and write the result into your own database without touching the Python stack at all.

Aggregating by model and day

A raw list of records is hard to read, so it gets folded into a table right away. If each record carries a model and a request date, aggregation is just a defaultdict:

bash
from collections import defaultdict
from datetime import datetime

by_day_model = defaultdict(float)

for r in records:
    day = r["created_at"][:10]
    model = r["model"]
    cost_usd = r["cost_cents"] / 100  # cost_cents is fractional cents
    by_day_model[(day, model)] += cost_usd

for (day, model), cost in sorted(by_day_model.items()):
    print(f"{day}  {model:30s}  ${cost:.4f}")

From there it either goes into a CSV for accounting, or into your own billing system if you resell access to clients on top of your key (take the per-client breakdown from your own logs — /v1/usage doesn't have it).

Cross-checking with response headers

Every regular (non-streaming) request to a model returns an X-Zukko-Cost-Micros header — the cost of that specific request in micro-dollars (1,000,000 = $1) — and X-Fetch-Balance-Cents, the remaining balance after the charge (despite the name, also in micro-dollars; the exact balance in cents is the balance_cents field of GET /v1/balance). Responses with stream: true don't carry these headers — for streaming clients (Cline, Roo, Claude Code, Zed, aider) you're left with /v1/usage, /v1/balance, and the dashboard. If your production code already logs API responses, you can write both headers into your own logs in real time and skip /v1/usage entirely for "how much did we spend in the last hour" — that is faster for live monitoring, while /v1/usage is better for after-the-fact reconciliation and reports.

It is also worth cross-checking the sum from /v1/usage against the current balance via GET /v1/balance (allowing for top-ups, which aren't in /v1/usage). If the numbers do not match, some responses with errors were probably dropped from your logging, or retries created duplicates.

What can go wrong

Frequent pulls aren't needed. /v1/usage has no rate limit, and the gateway has no overall rate limit either; a 429 with key_daily_limit_reached / key_monthly_limit_reached is about a key's spend limits on model requests, not about reading history. Still, there's no reason to hit /v1/usage once a second from a cron job: for regular reporting, pulling data once an hour or once a day is enough, and for live monitoring the response headers are handier.

An empty next_cursor means the end, not an error. The pagination loop should stop when the cursor is missing or empty in the response, rather than trying to pass an empty string into the next request — that is a common reason a pagination script hangs.

Different keys, one history. Whichever key you call /v1/usage with — even one with an allowed-models restriction — the response is the shared account-wide log, not a per-key log. Keep the per-key split on your side via X-Zukko-Cost-Micros, or use separate accounts.

If your reporting needs more than money — response times, dropped connections — /v1/usage will not give you that, it only records the fact of a charge. For latency and error monitoring, log that on your own side and tie the records to the same keys and projects you track spend by yourself.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

GET /v1/usage — API billing history for reporting · Zukko