# Token usage

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

Canonical page: https://www.rundiffusion.com/docs/api/reporting/token-usage
Endpoint: GET /api/v2/reporting/token-usage
Authorization: Company API Access Token

---

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.

`GET /api/v2/reporting/token-usage`

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

| Credential | Reporting |
| --- | --- |
| OAuth device flow | Not allowed (Names a person, not a company) |
| Personal API Access Token | Not allowed (Covers your data, not your company's) |
| Company API Access Token | Allowed (Enterprise 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.

> **Other credentials are refused, not narrowed**
>
> A Personal API Access Token and an OAuth token both name a person rather than
> a company, so neither returns a smaller version of this report. Both are
> refused, with a message naming the token to use instead.
>
> This is also the only v2 endpoint that requires an Enterprise plan. See
> [Authentication](/docs/api/authentication) for where a Company token comes
> from.

## Request

**Headers**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | string | Yes | 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](/docs/api/authentication). |

Every parameter is optional. With none of them you get the widest window the
endpoint allows, at the default page size.

**Query**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `start_utc` | string (ISO 8601) | No | Start of the window, inclusive, in UTC. Defaults to five years before end_utc, which is also the widest window allowed. |
| `end_utc` | string (ISO 8601) | No | End of the window, exclusive, in UTC. Defaults to now. A window wider than five years is rejected with 400. |
| `limit` | integer | No | 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. Default: `100000` |
| `cursor` | string | No | Opaque pagination cursor. Pass the next_cursor from the previous response to fetch the next page. Do not construct or parse one. |

### Making a request

cURL:

```bash
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"
```

JavaScript:

```javascript
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());
```

Python:

```python
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

```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

| Name | Type | Description |
| --- | --- | --- |
| `params` | object | 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. |
| `data` | array | The usage rows on this page. Empty when nothing falls in the window. |
| `has_more` | boolean | Whether more pages exist beyond this one. Branch on this when paging. |
| `next_cursor` | string \| null | Pass this back as cursor to fetch the next page. Null on the last page. |
| `last_cursor` | string \| 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. |

> **Page with next_cursor, not last_cursor**
>
> `last_cursor` is an echo of the cursor **you sent**, so it is `null` on the
> first page. The cursor for the *next* page is `next_cursor`, and `has_more`
> tells you whether another page exists.

### 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**

| Name | Type | Description |
| --- | --- | --- |
| `record_id` | string | Balance-record ID, unique per row. Use it to deduplicate when a re-run overlaps a window you already loaded. Example: Rw9dLs…. |
| `created` | string (ISO 8601) | When it was created, in UTC. |
| `team_name` | string \| null | Team the run belonged to. Null for personal usage. Example: Design. |
| `user_id` | string \| null | Who ran it, as a 20-character opaque ID. Group by this for per-person spend. Example: k3PqV9…. |
| `email` | string \| null | Email of that user, for readable reports. Example: sam@example.com. |
| `run_id` | string \| null | The run this usage came from. Several rows can share one. Example: Kp7mZq…. |
| `run_state` | string \| 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_id` | string \| null | Identifier of what was run. Group by this for per-tool spend. Example: 1kCVin…. |
| `tool_name` | string \| 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_id` | string \| null | Board the run happened in. Null when it was not run inside a board. Example: Rn4tKp…. |
| `board_title` | string \| null | Title of that board. Null alongside a null board_id. Example: Q3 campaign. |
| `total_tokens` | integer | Tokens consumed by the whole run, summed across every result in it. This is the field to sum for spend. Example: 25. |
| `num_results` | integer | How many images or videos the run produced. Example: 2. |
| `tokens_per_result` | number | 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_fields` | array | 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_fields` | array | 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

| Name | Type | Description |
| --- | --- | --- |
| `run_state` | string \| 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_id` | string \| 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_title` | string \| 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_result` | number | 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.

> **Overlap your window; do not resume exactly where you stopped**
>
> Because `created` records when a charge was committed, a row can appear
> slightly after that timestamp has passed. A sync that asks for
> `created > (the newest row I have)` can therefore step over a record that
> arrived a moment late, and nothing later will go back for it: the next run
> starts from an even higher watermark.
>
> Start each run a comfortable margin **behind** your newest row instead. The
> overlap re-reads rows you already hold, and because `record_id` is stable your
> upsert discards them. Periodically re-reading a wider range, such as the
> current and previous month, is a cheap way to confirm a table is complete.

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.

JavaScript:

```javascript
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
  }
}
```

Python:

```python
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_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:

JavaScript:

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

Python:

```python
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:

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

JavaScript:

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

Python:

```python
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 `429`s.

## Errors

| Name | Type | Description |
| --- | --- | --- |
| `400` | VALIDATION_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. |
| `401` | UNAUTHENTICATED | 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. |
| `403` | PERMISSION_DENIED | The token is valid but its company is not on an Enterprise plan. The same token still works on the user-scoped endpoints. |
| `429` | RATE_LIMITED | Too many requests. Back off and retry per the Retry-After header. See [Rate limits](/docs/api/rate-limits#reporting). |

See [Errors](/docs/api/errors) for the full envelope and the code list.

## Paging through everything

Follow `next_cursor` until `has_more` is false.

JavaScript:

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

Python:

```python
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:
            return
```

> **Page sizes are generous**
>
> The limit goes up to 100,000 rows, so most monthly exports fit in very few
> requests. Larger pages and fewer round trips will comfortably outrun the rate
> limit compared with many small ones.

## Rate 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](/docs/api/rate-limits#reporting).

> **One request at a time**
>
> Reporting allows a single in-flight request per company. This is the limit
> people trip over, because the instinct when an export feels slow is to fetch
> several pages or date ranges in parallel, and here that makes things worse
> rather than better: the extra requests are rejected while the first one is
> still running.
>
> Page sequentially and use a large `limit` instead. Fewer, bigger requests are
> both faster and further inside the limits than many small ones.

JavaScript:

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

Python:

```python
# 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](#loading-into-a-warehouse-or-bi-tool) describes.

> **Let the tool page, or page it yourself**
>
> Some BI tools can follow a cursor natively; others stop at the first page and
> quietly report a fraction of your usage. Confirm which yours does before
> trusting a total. If it cannot page, load the data with a small script on a
> schedule and point the dashboard at the result.

> **The numbers are company-wide, the token is a person's**
>
> This endpoint returns usage for the whole company, but the token doing the
> reading still belongs to whoever created it. Store it in your BI tool's secret
> store rather than an individual's configuration, and remember it keeps working
> only while its owner holds the **view company reports** permission and stays on
> a team in the company. For a dashboard that has to outlive any one employee,
> create the token from a dedicated service account. Regenerate or delete it if it
> leaks.
