Skip to content
Enterprise only

Token usage

Query token usage row by row for dashboards, cost allocation, and spend monitoring.

On this page

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.

GEThttps://api2.rundiffusion.com/api/v2/reporting/token-usage
AcceptsCompany API Access Token

Authorization

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.

Which RunDiffusion API credentials can call Reporting
CredentialReporting
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
bash
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
limitintegeroptionaldefault 100000
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"

Response

json
{
  "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.

sql
-- 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.

sql
-- "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:

sql
-- 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;
sql
-- 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
  }
}

The upsert is what makes the overlap free. Any database's equivalent works, as long as the key is record_id:

sql
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:

json
"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])) };
}

The report is then a straightforward group-by:

sql
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`);
}

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);
}

Rate limits

LimitValue
Per minute20 requests
Per hour120 requests
In flight at once1 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);
  }
}

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.

View as Markdown