Token usage
Query token usage row by row for dashboards, cost allocation, and spend monitoring.
On this page
- Authorization
- Request
- Making a request
- Response
- Top level
- Ordering
- Usage record object
- Reading the numbers correctly
- Refunds are negative rows
- numresults is what was requested
- Not every row is a finished generation
- created is when the charge was recorded
- Loading into a warehouse or BI tool
- Attributing cost with custom fields
- Errors and retries
- Errors
- Paging through everything
- Rate limits
- Connecting a BI tool
Reporting returns token usage one record at a time rather than pre-aggregated, so you can group and filter it however your business actually works. Teams commonly load it into a business intelligence tool to build spend dashboards and cost allocation reports.
https://api2.rundiffusion.com/api/v2/reporting/token-usageAuthorization
Reporting takes one credential: a Company API Access Token. It is the only one that names a company, and this endpoint answers for a whole company.
| Credential | Reporting |
|---|---|
| OAuth device flowAn ID token from the RFC 8628 device code flow. Works for personal and team calls alike, so it is the alternative to either token. | Not allowedNames a person, not a company |
| Personal API Access TokenFor personal plans. A long-lived key that acts as you on your personal account: your library, boards, uploads, and generations. | Not allowedCovers your data, not your company's |
| Company API Access TokenFor team and enterprise plans. A long-lived key that acts as you on your company's teams, plus company usage reporting. | AllowedEnterprise plans, and the owner needs the reports permission |
curl -G https://api2.rundiffusion.com/api/v2/reporting/token-usage \
-H "Authorization: Bearer $RUNDIFFUSION_API_TOKEN" \
--data-urlencode "limit=1000"That is the entire account selection here. There is no team_id on this
endpoint: the token already belongs to a company, so there is nothing left to
name, and the numbers are that company's.
Two things are checked on every call: the token is company-scoped, and the user behind it holds the view company reports permission on that company. Losing the permission stops reporting immediately while leaving the token working everywhere else, so revoking somebody's access does not mean hunting down their credentials.
Request
Headers
AuthorizationstringRequired- Bearer followed by a Company API Access Token, created on the Plugins & APIs page by an account administrator. A Personal API Access Token and an OAuth token are both rejected: this endpoint returns company-wide data, so the credential has to name a company. The user behind the token also needs the view company reports permission. See Authentication.
Authorization: Bearer q7Xk2mR9vT4…
Every parameter is optional. With none of them you get the widest window the endpoint allows, at the default page size.
Query
start_utcstring (ISO 8601)optional- Start of the window, inclusive, in UTC. Defaults to five years before end_utc, which is also the widest window allowed.
https://api2.rundiffusion.com/api/v2/reporting/token-usage?start_utc=2026-07-01T00:00:00Z end_utcstring (ISO 8601)optional- End of the window, exclusive, in UTC. Defaults to now. A window wider than five years is rejected with 400.
https://api2.rundiffusion.com/api/v2/reporting/token-usage?start_utc=2026-07-01T00:00:00Z&end_utc=2026-08-01T00:00:00Z limitintegeroptionaldefault100000- Rows per page, from 1 to 100,000. The default is the maximum, so a query returns everything in the window unless you page it deliberately.
https://api2.rundiffusion.com/api/v2/reporting/token-usage?limit=1000 cursorstringoptional- Opaque pagination cursor. Pass the next_cursor from the previous response to fetch the next page. Do not construct or parse one.
https://api2.rundiffusion.com/api/v2/reporting/token-usage?cursor=eyJjIjoiMjAyNi0…
Making a request
curl -G https://api2.rundiffusion.com/api/v2/reporting/token-usage \
-H "Authorization: Bearer $RUNDIFFUSION_API_TOKEN" \
--data-urlencode "start_utc=2026-07-01T00:00:00Z" \
--data-urlencode "end_utc=2026-08-01T00:00:00Z" \
--data-urlencode "limit=1000"const params = new URLSearchParams({
start_utc: '2026-07-01T00:00:00Z',
end_utc: '2026-08-01T00:00:00Z',
limit: '1000',
});
const page = await fetch(
`https://api2.rundiffusion.com/api/v2/reporting/token-usage?${params}`,
{ headers: { Authorization: `Bearer ${process.env.RUNDIFFUSION_API_TOKEN}` } },
).then(r => r.json());import os
import requests
page = requests.get(
"https://api2.rundiffusion.com/api/v2/reporting/token-usage",
headers={"Authorization": f"Bearer {os.environ['RUNDIFFUSION_API_TOKEN']}"},
params={
"start_utc": "2026-07-01T00:00:00Z",
"end_utc": "2026-08-01T00:00:00Z",
"limit": 1000,
},
).json()Response
{
"params": {
"start_utc": "2026-07-01T00:00:00.000Z",
"end_utc": "2026-08-01T00:00:00.000Z",
"limit": 1000
},
"data": [
{
"record_id": "Rw9dLs…",
"created": "2026-07-14T09:31:02.184Z",
"team_name": "Design",
"user_id": "k3PqV9…",
"email": "sam@example.com",
"run_id": "Kp7mZq…",
"run_state": "DONE",
"tool_id": "1kCVin…",
"tool_name": "Nano Banana 🍌",
"board_id": null,
"board_title": null,
"total_tokens": 25,
"num_results": 2,
"tokens_per_result": 12.5,
"team_custom_fields": [
{ "key": "cost_center", "label": "Cost center", "value": "DESIGN-004" }
],
"board_custom_fields": []
}
],
"last_cursor": null,
"next_cursor": "eyJjIjogIjIwMjYt…",
"has_more": true
}Timestamps carry millisecond precision. tokens_per_result is total_tokens
divided by num_results, so it is a fraction whenever a run's cost does not
divide evenly. It is 0 for a run that consumed nothing.
Top level
paramsobject- The window and limit actually applied, including any defaults filled in for you, as { start_utc, end_utc, limit }. Log this rather than your own inputs when you need to explain where a number came from.
dataarray- The usage rows on this page. Empty when nothing falls in the window.
has_moreboolean- Whether more pages exist beyond this one. Branch on this when paging.
next_cursorstring | null- Pass this back as cursor to fetch the next page. Null on the last page.
last_cursorstring | null- Echoes the cursor you sent on this request. Null on the first page. It points backwards, so paging with it stops after one page.
Ordering
Rows come back oldest first, ordered by created ascending with record_id
breaking ties. That is the opposite of the other list endpoints, and it is
deliberate: appending to a table you are building works naturally when the
oldest row arrives first.
The tiebreak matters more than it looks. Many records can share a created
value, so ordering on the timestamp alone would not be stable and a cursor could
skip or repeat rows at a page boundary. The pair is unique, so paging is stable.
Usage record object
Usage record
record_idstring- Balance-record ID, unique per row. Use it to deduplicate when a re-run overlaps a window you already loaded. Example: Rw9dLs….
createdstring (ISO 8601)- When it was created, in UTC.
team_namestring | null- Team the run belonged to. Null for personal usage. Example: Design.
user_idstring | null- Who ran it, as a 20-character opaque ID. Group by this for per-person spend. Example: k3PqV9….
emailstring | null- Email of that user, for readable reports. Example: sam@example.com.
run_idstring | null- The run this usage came from. Several rows can share one. Example: Kp7mZq….
run_statestring | null- Terminal state of the run. DONE means it produced results; ERROR means it did not, and those rows are usually zero-token. Example: DONE.
tool_idstring | null- Identifier of what was run. Group by this for per-tool spend. Example: 1kCVin….
tool_namestring | null- Display name for that identifier at the time of the run. Names can change, so group by tool_id and label with this. Example: Nano Banana 🍌.
board_idstring | null- Board the run happened in. Null when it was not run inside a board. Example: Rn4tKp….
board_titlestring | null- Title of that board. Null alongside a null board_id. Example: Q3 campaign.
total_tokensinteger- Tokens consumed by the whole run, summed across every result in it. This is the field to sum for spend. Example: 25.
num_resultsinteger- How many images or videos the run produced. Example: 2.
tokens_per_resultnumber- total_tokens divided by num_results, so a fraction whenever the cost does not divide evenly, and 0 for a run that consumed nothing. Example: 12.5.
team_custom_fieldsarray- Team-level custom fields as { key, label, value } objects, for example { "key": "cost_center", "label": "Cost center", "value": "DESIGN-004" }. Only entries with a value appear, so the array is empty when none are set. This is what cost allocation reports usually group by.
board_custom_fieldsarray- Board-level custom fields, same { key, label, value } shape as team_custom_fields. Empty when the run was not in a board or the board has none set.
Reading the numbers correctly
The endpoint returns a ledger, not a summary. A few of its properties will quietly produce wrong totals if you assume otherwise, so they are worth knowing before you build a report on it.
Refunds are negative rows
When work is refunded, that does not edit the original row. A second row is
written with a negative total_tokens, and the two are meant to be summed.
-- Correct: refunds cancel out the charges they belong to.
SELECT SUM(total_tokens) AS net_tokens FROM token_usage;
-- Wrong: silently drops every refund and overstates spend.
SELECT SUM(total_tokens) FROM token_usage WHERE total_tokens > 0;There is no server-side netting per run, so any filter that keeps only positive values inflates the total. Sum, and let the signs do the work.
num_results is what was requested
It is read from the run's settings when the run is created, so it reflects the
number of results asked for, not the number successfully produced. Compare
it against run_state rather than treating it as a delivery count.
It is also counted only on the charge row: refund rows and session-based charges
report 0, so a run's num_results is not double-counted when you aggregate.
-- "How many results did we actually get, and what did they cost?"
-- num_results counts what was asked for, so gate it on the run succeeding.
SELECT
SUM(total_tokens) AS net_tokens,
SUM(CASE WHEN run_state = 'DONE' THEN num_results END) AS results_delivered,
SUM(num_results) AS results_requested
FROM token_usage
WHERE created >= '2026-07-01' AND created < '2026-08-01';The gap between the two counts is work that was charged for and then errored.
Refunds land in net_tokens on their own rows, so the money side stays correct
without any special handling.
Not every row is a finished generation
run_statestring | null- Rows exist for runs that later errored, so this is not always DONE. Filter on it if you are counting successful work rather than money spent.
run_idstring | null- Null on session-based charges, which are not tied to a single run. An inner join on run_id silently drops them; use a left join.
board_id / board_titlestring | null- Null unless the work happened inside a board. Group by them only after deciding what an ungrouped row should mean in your report.
tokens_per_resultnumber- total_tokens divided by num_results, and 0 whenever either is zero or negative, which includes every refund row. Derive your own ratio from summed totals rather than averaging this column.
Two of these change how you write a join and an average:
-- Cost per result, per tool.
-- Divide summed totals; averaging tokens_per_result would weight every row
-- equally and let the zeros on refund rows drag the figure down.
SELECT
tool_name,
SUM(total_tokens) AS net_tokens,
SUM(CASE WHEN run_state = 'DONE' THEN num_results END) AS results,
SUM(total_tokens)::numeric
/ NULLIF(SUM(CASE WHEN run_state = 'DONE' THEN num_results END), 0)
AS tokens_per_result
FROM token_usage
GROUP BY tool_name
ORDER BY net_tokens DESC;-- Joining out to your own run-level table: LEFT JOIN, not INNER.
-- Session-based charges have no run_id, and an inner join drops them, which
-- quietly lowers your reported spend.
SELECT u.record_id, u.total_tokens, r.project_code
FROM token_usage u
LEFT JOIN my_runs r ON r.run_id = u.run_id;created is when the charge was recorded
created is the moment the usage record was committed to the ledger, which can
be shortly after the generation itself. For daily and monthly rollups the
difference is invisible. For intraday charts it can move a record into a later
bucket than you would expect.
It also means a record can become visible slightly after its created
timestamp. If you sync incrementally, see below.
Loading into a warehouse or BI tool
Most integrations copy this data on a schedule into a table their reporting tool reads, rather than querying it live. Three properties make that straightforward.
record_id is stable and unique. The same record always comes back with the
same ID, so an upsert keyed on it is idempotent. Re-reading a window you already
have changes nothing, which makes re-ingestion a safe recovery step rather than
a risky one.
Re-reading is cheap. With a page limit up to 100,000 rows, most windows fit in very few requests, so widening a range costs little.
Rows are oldest first, so an incremental load appends in natural order.
The whole pattern is the paging loop plus two things: a start time that steps back from where you finished, and an upsert that makes re-reading harmless.
const URL = 'https://api2.rundiffusion.com/api/v2/reporting/token-usage';
const HEADERS = { Authorization: `Bearer ${process.env.RUNDIFFUSION_API_TOKEN}` };
// Re-read this far behind the newest row you already have.
const LOOKBACK_MS = 24 * 60 * 60 * 1000;
async function* fetchPages(startUtc, endUtc) {
let cursor;
while (true) {
const params = new URLSearchParams({
start_utc: startUtc.toISOString(),
end_utc: endUtc.toISOString(),
limit: '100000',
});
if (cursor) params.set('cursor', cursor);
const page = await requestWithRetry(params);
yield page.data;
if (!page.has_more) return;
cursor = page.next_cursor;
}
}
async function sync() {
const newest = await warehouseMaxCreated(); // null on a first run
const start = newest ? new Date(newest.getTime() - LOOKBACK_MS) : new Date('2020-01-01');
const end = new Date();
for await (const rows of fetchPages(start, end)) {
await upsert(rows); // keyed on record_id
}
}import os, time, requests
from datetime import datetime, timedelta, timezone
URL = "https://api2.rundiffusion.com/api/v2/reporting/token-usage"
HEADERS = {"Authorization": f"Bearer {os.environ['RUNDIFFUSION_API_TOKEN']}"}
# Re-read this far behind the newest row you already have. Bigger is safer and
# costs only duplicate rows your upsert throws away; size it to how late you are
# willing to tolerate a record arriving.
LOOKBACK = timedelta(days=1)
def fetch_pages(start_utc, end_utc):
"""Yield each page in order. One request at a time, by design."""
cursor = None
while True:
params = {
"start_utc": start_utc.isoformat().replace("+00:00", "Z"),
"end_utc": end_utc.isoformat().replace("+00:00", "Z"),
"limit": 100_000,
}
if cursor:
params["cursor"] = cursor
page = request_with_retry(params)
yield page["data"]
if not page.get("has_more"):
return
cursor = page["next_cursor"]
def sync():
newest = warehouse_max_created() # None on a first run
start = (newest - LOOKBACK) if newest else datetime(2020, 1, 1, tzinfo=timezone.utc)
end = datetime.now(timezone.utc)
for rows in fetch_pages(start, end):
upsert(rows) # keyed on record_idThe upsert is what makes the overlap free. Any database's equivalent works, as
long as the key is record_id:
INSERT INTO token_usage (record_id, created, team_name, email, tool_name,
run_state, total_tokens, num_results)
VALUES (...)
ON CONFLICT (record_id) DO UPDATE SET
created = EXCLUDED.created,
team_name = EXCLUDED.team_name,
email = EXCLUDED.email,
tool_name = EXCLUDED.tool_name,
run_state = EXCLUDED.run_state,
total_tokens = EXCLUDED.total_tokens,
num_results = EXCLUDED.num_results;With that in place, re-running the sync over any range is safe, which also makes a full backfill and a recovery re-read the same operation: widen the window and run it again.
Attributing cost with custom fields
team_custom_fields and board_custom_fields are the hook for cost allocation
reporting. Each is a list of { key, label, value } objects defined by your
company, so a cost centre, client code, or project name configured in
RunDiffusion arrives on every usage row without you maintaining a mapping.
They arrive as an array per row:
"team_custom_fields": [
{ "key": "cost_center", "label": "Cost center", "value": "DESIGN-004" },
{ "key": "client", "label": "Client", "value": "Northwind" }
]Only fields with a value are included, so a key you expect can simply be absent. Treat that as unset rather than as an error, and flatten the ones you report on into their own columns as you load:
function flatten(row, keys = ['cost_center', 'client']) {
const byKey = Object.fromEntries(
(row.team_custom_fields ?? []).map(f => [f.key, f.value]),
);
const { team_custom_fields, board_custom_fields, ...rest } = row;
// ?? null rather than assuming presence: an unset field is absent.
return { ...rest, ...Object.fromEntries(keys.map(k => [k, byKey[k] ?? null])) };
}def flatten(row, keys=("cost_center", "client")):
"""Lift selected custom fields to top-level columns."""
by_key = {f["key"]: f["value"] for f in row.get("team_custom_fields") or []}
return {
**{k: v for k, v in row.items() if not k.endswith("_custom_fields")},
# .get() rather than [], since an unset field is absent, not empty.
**{k: by_key.get(k) for k in keys},
}The report is then a straightforward group-by:
SELECT cost_center, SUM(total_tokens) AS net_tokens
FROM token_usage
WHERE created >= '2026-07-01' AND created < '2026-08-01'
GROUP BY cost_center
ORDER BY net_tokens DESC;Rows whose cost centre was never set group under NULL. That is usually worth
surfacing in the report rather than filtering out, since it is exactly the spend
nobody has claimed.
Errors and retries
Retry a 5xx or a timeout with backoff. If it still fails, fail the run
loudly rather than skipping the page and moving on: a skipped page leaves a
hole that no later run will return to, and a report that is quietly short is
worse than one that is obviously broken.
429 deserves its own branch. It means you are asking too often, not that
anything went wrong, so honour Retry-After and carry on rather than counting
it as a failure.
const MAX_ATTEMPTS = 5;
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function requestWithRetry(params) {
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const res = await fetch(`${URL}?${params}`, { headers: HEADERS });
if (res.ok) return res.json();
// Throttled: not an error. Wait the advertised time and try again.
if (res.status === 429) {
await sleep(Number(res.headers.get('Retry-After') ?? 30) * 1000);
continue;
}
// 4xx other than 429 will fail identically next time, so stop now.
if (res.status < 500) throw new Error(`${res.status} ${await res.text()}`);
await sleep(2 ** attempt * 1000); // 1, 2, 4, 8, 16 seconds
}
// Out of attempts. Throw, so the run fails visibly and this window gets
// picked up next time, rather than leaving a hole in the table.
throw new Error(`giving up after ${MAX_ATTEMPTS} attempts`);
}MAX_ATTEMPTS = 5
def request_with_retry(params):
for attempt in range(MAX_ATTEMPTS):
r = requests.get(URL, headers=HEADERS, params=params, timeout=300)
if r.status_code == 200:
return r.json()
# Throttled: not an error. Wait the advertised time and try again.
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 30)))
continue
# 4xx other than 429 will fail identically next time, so stop now.
if r.status_code < 500:
r.raise_for_status()
time.sleep(2 ** attempt) # 1, 2, 4, 8, 16 seconds
# Out of attempts. Raise, so the run fails visibly and this window gets
# picked up next time, rather than leaving a hole in the table.
raise RuntimeError(f"giving up on {params} after {MAX_ATTEMPTS} attempts")Note the loop is sequential throughout. Because only one request may be in
flight at a time, retrying a page while another is still running just produces
more 429s.
Errors
400VALIDATION_ERROR- A parameter failed validation. The common causes are a window wider than five years, an end_utc earlier than start_utc, a limit outside 1 to 100,000, and a malformed timestamp.
401UNAUTHENTICATED- Missing or invalid credential. Also returned when the credential is valid but is not a Company API Access Token, which is the case for a Personal one and for an OAuth token, or when the user behind it lacks the view company reports permission.
403PERMISSION_DENIED- The token is valid but its company is not on an Enterprise plan. The same token still works on the user-scoped endpoints.
429RATE_LIMITED- Too many requests. Back off and retry per the Retry-After header. See Rate limits.
See Errors for the full envelope and the code list.
Paging through everything
Follow next_cursor until has_more is false.
async function* allUsage(startUtc, endUtc) {
let cursor;
while (true) {
const params = new URLSearchParams({ start_utc: startUtc, end_utc: endUtc });
if (cursor) params.set('cursor', cursor);
const page = await fetch(
`https://api2.rundiffusion.com/api/v2/reporting/token-usage?${params}`,
{ headers: { Authorization: `Bearer ${process.env.RUNDIFFUSION_API_TOKEN}` } },
).then(r => r.json());
yield* page.data;
if (!page.has_more || !page.next_cursor) return;
cursor = page.next_cursor;
}
}
for await (const row of allUsage('2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z')) {
console.log(row.record_id, row.total_tokens);
}def all_usage(start_utc, end_utc):
cursor = None
while True:
params = {"start_utc": start_utc, "end_utc": end_utc}
if cursor:
params["cursor"] = cursor
page = requests.get(
"https://api2.rundiffusion.com/api/v2/reporting/token-usage",
headers=HEADERS,
params=params,
).json()
yield from page["data"]
cursor = page.get("next_cursor")
if not page.get("has_more") or not cursor:
returnRate limits
| Limit | Value |
|---|---|
| Per minute | 20 requests |
| Per hour | 120 requests |
| In flight at once | 1 request |
All three apply at once, and exceeding any of them returns 429 with
RATE_LIMITED. See Rate limits.
// Wrong: every one of these but the first is rejected while the first runs.
const months = ['2026-05', '2026-06', '2026-07'];
const pages = await Promise.all(months.map(fetchMonth)); // -> 429s
// Right: sequential, with a page size that makes the trip worth taking.
for (const month of months) {
for await (const rows of fetchPages(...monthBounds(month))) { // limit=100000
await upsert(rows);
}
}# Wrong: every one of these but the first is rejected while the first runs.
months = ["2026-05", "2026-06", "2026-07"]
with ThreadPoolExecutor() as pool:
pages = pool.map(fetch_month, months) # -> 429s
# Right: sequential, with a page size that makes the trip worth taking.
for month in months:
for rows in fetch_pages(*month_bounds(month)): # limit=100_000
upsert(rows)Connecting a BI tool
The endpoint is ordinary paginated JSON over HTTPS with bearer auth, so anything
that can call a REST API can consume it. For tools such as Power BI, point a web
data source at the endpoint, supply the Authorization header, and follow
next_cursor to page.
Pointing a dashboard straight at the endpoint works for a small window, but it re-fetches everything on each refresh and gets slower as history grows. Loading into a table on a schedule and reporting from that scales better, and it is what the warehouse guidance above describes.
