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:
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.
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:
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:
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:
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.
Related articles
- Prompt Injection: What It Is and How to Protect a Bot and an AgentHow prompt injection works through user input and third-party documents, and which measures limit the damage: data isolation, tool permissions, key limits.
- Your Own Neural Network Proxy on Node.js: Keep the API Key Off the FrontendA small Express server between your frontend and the model API: the key stays on the server, and models, request size and rate are capped.
- DeepSeek API in Python: Requests, Streaming, JSON and Error HandlingReady code for DeepSeek V4.1 Flash and V4 Pro on the OpenAI SDK: a request, streaming, JSON parsing and careful error handling.
- Prompt Engineering Examples: 12 Ready-Made Templates for WorkTwelve prompt templates for everyday tasks: what each one says, why it is built that way, and what a single API call costs.