> ## Documentation index
> Fetch the complete documentation index at: https://rolter-ai-rolter.docs7.io/llms.txt
> Ask it with GET https://rolter-ai-rolter.docs7.io/ask?q=<keywords or question>. Short keywords rank best.
> Use these to discover all available pages before exploring further.

# Cost Attribution: Business Units and Customers

> Attribute spend to business units through virtual keys, and to your own customers, without breaking the identity that historical spend was recorded against.

Rolter attributes spend along two axes that sit beside the org/team/project hierarchy:

- **Business units** collect spend from the virtual keys assigned to them, and from customers that roll up into them, so cost lands on the part of the organisation that incurred it. Teams carry no business unit: until a key is assigned, a unit shows no spend.
- **Customers** attribute usage to the people _you_ serve, so it can be billed on or reported per account. A customer can optionally roll up into a business unit.

Both live under **Governance** in the dashboard.

---

## Slugs are the identity

Every unit and customer has a `name` (for humans) and a `slug` (for everything else). The slug is what spend is recorded against, and it is derived from the name when you leave it blank.

It must match `^[a-z0-9][a-z0-9-]{0,62}$` — lowercase alphanumerics and dashes, starting with a letter or digit.

**A slug is immutable by default.** Renaming one does not rewrite the attribution already recorded against the old value, so the API refuses a rename unless you pass `allow_slug_change: true`, and the dashboard makes you tick a box that names the old slug explicitly. This is deliberate friction: a silent rename produces a report that is quietly wrong rather than loudly broken.

---

## Retire, don't delete

A unit or customer with history should be **retired**, not deleted. Retiring sets `retired_at`, keeps every record that points at it, and stops it being offered for new attribution — the dashboard keeps it visible, dimmed and badged, and drops it from the assignment dropdown unless it is the one already selected.

Delete removes the row outright. Use it for something created by mistake, not for a customer who churned.

---

## Assignment

A customer's `business_unit_id` follows a three-state contract on update:

| Value   | Meaning             |
| ------- | ------------------- |
| omitted | leave unchanged     |
| `null`  | unassign            |
| a UUID  | assign to that unit |

The unit must belong to the same org as the customer; a cross-org assignment is rejected.

---

## Pointing a key at a unit or a customer

Spend is attributed by the **virtual key** that served the request. A key carries an optional `business_unit_id` and an optional `customer_id`, and every request it serves is recorded against both.

In the dashboard, open **Virtual Keys**, press the pencil on a row, and pick a business unit and a customer. The same two controls sit in the **Create virtual key** sheet, so a key can be attributed from the moment it exists. Each row shows where its spend lands; a key with no attribution shows a dash rather than a badge, because leaving a key unattributed is a legitimate choice, not a warning.

Two rules the editor enforces before you save, because the server rejects them afterwards:

- The unit and the customer must belong to the key's own org.
- A customer already owned by a business unit cannot be paired with a different one. Only customers that fit the unit you picked are offered.

Both fields follow the same three-state contract customers use for `business_unit_id`:

| Value   | Meaning                            |
| ------- | ---------------------------------- |
| omitted | leave unchanged                    |
| `null`  | clear the attribution              |
| a UUID  | attribute to that unit or customer |

The dashboard always sends both fields, so clearing one is something you can actually express.

```bash
curl -X PUT -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"business_unit_id": "'"$UNIT_ID"'", "customer_id": null}' \
  https://control.example.com/api/v1/virtual-keys/$KEY_ID/attribution
```

<Note>
Creating a key and attributing it are two calls. `POST /virtual-keys` takes the provider allow-list but not the attribution, so the dashboard follows a create that chose one with the `attribution` PUT.
</Note>

---

## Per-key provider allow-list

Beside the attribution, a key carries a list of upstream provider **slugs** it may reach. An empty list is the permissive default — the key may reach every provider on a route it is already allowed to use — so ticking nothing does not lock the key out of everything.

The dashboard offers the org's providers as checkboxes in both the create sheet and the per-row editor, and the self-service **Account** panel offers the same list plus the response-cache override when you mint your own key.

```bash
curl -X PUT -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" \
  -H "Content-Type: application/json" -d '{"providers": ["openai", "anthropic"]}' \
  https://control.example.com/api/v1/virtual-keys/$KEY_ID/providers
```

---

## Reading the spend

`GET /api/v1/analytics/by-attribution` rolls `request_logs` up by one governance dimension over a time window:

| Parameter              | Meaning                                                                                                                                                     |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `since` / `until`      | window bounds in one of the [time window](/observability/logs-and-cost#time-windows) forms; both default to the last 7 days, and a malformed one is a `400` |
| `dimension`            | `business_unit` or `customer` (defaults to `business_unit`)                                                                                                 |
| `include_unattributed` | include the bucket for keys that named neither (defaults to `false`)                                                                                        |

```bash
curl -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" \
  "https://control.example.com/api/v1/analytics/by-attribution?dimension=customer&include_unattributed=true"
```

Each row carries `id`, `requests`, `tokens`, `prompt_tokens`, `completion_tokens`, `cost_usd` and `errors`. The unattributed bucket comes back with an **empty** `id`.

The **Business Units** and **Customers** screens read this over a window you pick beside the **New** button, and show three figures above the roster: what the window cost in total, how much of it was attributed, and how much was not, with the unattributed share as a percentage. Each card then carries its own spend and request count; a unit with no traffic says so rather than printing a zero, because a zero claims a fact the window does not contain.

| Window            | What it covers                                                                   |
| ----------------- | -------------------------------------------------------------------------------- |
| **Last 24 hours** | the 24 hours before the figures were read; the default, as on the Dashboard      |
| **Last 7 days**   | the 7 × 24 hours before the figures were read                                    |
| **Last 30 days**  | the 30 × 24 hours before the figures were read                                   |
| **Month to date** | from midnight on the first of the current month up to now                        |
| **Last month**    | the whole previous calendar month, from its first midnight to this month's first |

The two month windows follow your browser's time zone, so "last month" is the month on your own calendar, whatever zone the control plane or ClickHouse runs in, and a chargeback run on the 3rd still covers the previous month in full. The total's caption gives the first and last date the figures cover, which is where to check that. The rolling windows are worked out again each time the figures are read, so a tab left open does not keep widening its "last 24 hours".

The picked window is kept in the address as `?window=` (`24h`, `7d`, `30d`, `mtd` or `last-month`), so a reload or a shared link opens on the same window, and an unknown value falls back to the last 24 hours. It also carries from one screen to the other: pick **Last month** on **Business Units**, open **Customers**, and the customers are read over the same month.

To reproduce a figure from a script, send the bounds the screen sends. For last month, read from a UTC+3 browser on any day of October 2026:

```bash
curl -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" \
  "https://control.example.com/api/v1/analytics/by-attribution?dimension=business_unit&include_unattributed=true&since=2026-08-31T21:00:00.000Z&until=2026-09-30T21:00:00.000Z"
```

The rolling windows and month to date send only `since`, so the control plane's clock closes the window.

Amounts are rendered in the deployment's settlement currency, not a hardcoded `$`.

<Note>
The rollup needs ClickHouse. A deployment with no `clickhouse_url` shows a "Spend figures need ClickHouse" note in place of the spend figures, naming `CLICKHOUSE_URL` as the setting to add. The note is information about the deployment rather than an error, so it offers no retry and stays until the setting changes. The roster itself keeps working — it is backed by Postgres.
</Note>

---

## Exporting for chargeback

**Export CSV** on Business Units and Customers downloads the rollup for the window on screen, unattributed spend included, as `cost-attribution-<dimension>_<from>_to_<to>_<CURRENCY>.csv`. A row whose spend leaves out requests with no price is marked `cost_is_partial`, so a partial total is never read as final. **Export CSV** on LLM Logs downloads the request log under the filters on screen, metadata only. Both show only the rows you could list yourself. See [CSV exports](/api/dashboard-analytics#csv-exports).

## Attribution in the request log

Every row in `request_logs` carries `business_unit_id` and `customer_id`, so **LLM Logs** can be read per unit or per customer. The filter rail offers both dimensions, and the detail drawer names the unit and the customer a request was charged to.

`GET /api/v1/analytics/invocations` filters on both dimensions itself:

```bash
curl -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" \
  "https://control.example.com/api/v1/analytics/invocations?business_unit=<uuid>&since=2026-01-01T00:00:00Z"
```

Both accept a comma-separated set — `customer=<uuid>,<uuid>` — matching the filter rail, where several can be ticked at once. An id that nothing was attributed to matches nothing: a filter narrows, it never widens.

Because the filtering happens in the database, `limit` and the page cursor mean what they say under a filter. Paging through a unit's requests pages through _that unit's_ requests — see [paging the invocation log](/observability/logs-and-cost#paging-the-invocation-log).

<Note>
For a whole-window total rather than individual requests, `by-attribution` is still the cheaper question — it aggregates in ClickHouse instead of returning rows.
</Note>

---

## API

Listing needs **Viewer** on the org; creating, updating and deleting need **Admin**. Every mutation is audited.

Each row in a listing carries `live_key_count`, the number of enabled, unexpired virtual keys attributed to it, so a unit or customer with no spend can be told apart from one no key points at. The dashboard says _no key assigned_ on such a card and links to **Virtual Keys**. See [Attribution listings](/api/attribution-listings).

```bash
curl -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" \
  https://control.example.com/api/v1/orgs/$ORG_ID/business-units

curl -X POST -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Platform Engineering"}' \
  https://control.example.com/api/v1/orgs/$ORG_ID/business-units

curl -X POST -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Corp", "business_unit_id": "'"$UNIT_ID"'"}' \
  https://control.example.com/api/v1/orgs/$ORG_ID/customers

# retire rather than delete once there is history
curl -X PUT -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" \
  -H "Content-Type: application/json" -d '{"retired": true}' \
  https://control.example.com/api/v1/customers/$CUSTOMER_ID
```
