> For the complete documentation index, see [llms.txt](https://docs.revenium.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.revenium.io/track-and-control-costs/attribute-spend-to-departments.md).

# Attribute Spend to Departments

Once AI spend is being metered per developer, the next question is always the same one: which department is that spend coming from? Finance wants a chargeback line per department. Engineering leadership wants to know whether the Payments department's usage justifies its seat count. Neither question is answerable from a flat list of individual developers.

Departments are Revenium's answer. You describe your reporting structure once, map each person to a department, and every cost report can then be grouped by department. The structure lives in Revenium, not in your telemetry, which means you can build it after the data is already flowing, without re-sending or reprocessing a single event.

You build the department tree by uploading a CSV file or by calling the API, and you can add departments and move people by hand in the app. You can also keep the tree in sync with your directory by scheduling a job on your side that sends your roster to the import API (see [Keep Departments in Sync Automatically](#keep-departments-in-sync-automatically)). Spend from people you have not mapped is never dropped. It appears under **No department assigned**, and the app lists those people so you can assign them (see [Where Unmapped Spend Appears](#where-unmapped-spend-appears)).

Set up departments and assign people to them before department charts and filters show their spend.

***

### <i class="fa-sitemap">:sitemap:</i> What a Department Is

A department is one node in a tree that mirrors how your organization actually reports. `Engineering`, `Engineering/Platform`, and `Engineering/Platform/Payments` are three departments, each a child of the one before it.

Each person is assigned to exactly one department as their **primary** membership, and that assignment is dated. Departments can nest as many levels as your reporting lines need. Because the whole tree carries a path, a rollup at `Engineering` includes every sub-department beneath it, so you can look at one department or drill into a single sub-department without maintaining two different structures.

Two ideas do the work here, and both matter for the sections below:

* **A person is not a user account.** People in a department are directory records. Creating one does not create a Revenium login, does not send an invitation, and does not require the person to have sent any telemetry yet. You can load your entire roster on day one, before a single developer has been onboarded.
* **Attribution is resolved when you run the report, not when the event arrives.** Nothing about a department is stamped onto a usage event. The department is worked out at query time by looking up who the person was reporting to at the moment the event happened. This is what makes the two behaviours described in **Retroactive Attribution** and **When Someone Changes Departments** below possible.

***

### <i class="fa-file-csv">:file-csv:</i> The CSV Contract

Bulk import takes a CSV with this header:

```
email,department_path
```

An optional third column, `is_primary`, is accepted. Columns are read **by name**, so their order in the file does not matter. Files written against the older header `org_unit_path` keep working: it is accepted as another name for `department_path`.

| Column            | Required | Meaning                                                                                          |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------ |
| `email`           | Yes      | The developer's email address, matching the identity their tooling reports.                      |
| `department_path` | Yes      | Slash-separated department **names** from the root, for example `Engineering/Platform/Payments`. |
| `is_primary`      | No       | Only primary memberships are supported. Omit it, or set it to `true`.                            |

A realistic file:

```csv
email,department_path
ada.lovelace@example.com,Engineering/Platform/Payments
alan.turing@example.com,Engineering/Platform/Payments
grace.hopper@example.com,Engineering/Platform/Identity
katherine.johnson@example.com,Engineering/Developer Experience
margaret.hamilton@example.com,Engineering/Developer Experience
barbara.liskov@example.com,Data/Analytics Engineering
edsger.dijkstra@example.com,Data/Analytics Engineering
sophie.wilson@example.com,Product/Design Systems
```

You do not create the tree first. Every department along each path is created if it does not already exist, so the eight rows above build the whole structure on their own, including the intermediate `Engineering/Platform` node that no row names directly. Likewise, a person who is not yet known to Revenium is created and linked automatically.

Repeat imports are safe. A row whose person is already in the department it names is reported as unchanged rather than written again, so running the import again with a slightly edited roster does not duplicate anything.

{% hint style="info" %}
**File limits.** Uploads are capped at 5 MB and 20,000 rows. A file that exceeds either is rejected with an error rather than partially applied.
{% endhint %}

***

### <i class="fa-upload">:upload:</i> Importing in the App

Under **Settings > Departments**, in the **Import departments from a CSV** card, choose **Upload a CSV**, pick your file, and select **Check file**. The check is the dry run described below: it shows how many departments and assignments the file would add, names any row that needs fixing by line number, and saves nothing. When the preview looks right, select **Import**. A template with the two columns is available from the same card under **Download a template**.

Tick **This file is my full list** when the file is your whole roster rather than a list of additions. See [When someone leaves](#when-someone-leaves) for what that does.

### <i class="fa-play">:play:</i> Importing over the API: Dry Run First, Then For Real

The import endpoint accepts `multipart/form-data` with a file part named `file`.

Start with a dry run:

```bash
curl -X POST "https://api.revenium.ai/profitstream/v2/api/departments/import/csv?teamId=YOUR_TEAM_ID&dryRun=true" \
  -H "x-api-key: YOUR_API_KEY" \
  -F "file=@roster.csv"
```

A dry run is not a separate validation routine that might drift from the real thing. It runs the genuine import (the same department lookups, the same person resolution, the same membership planning) inside a transaction that is rolled back at the end. It is therefore both a true rehearsal and guaranteed to write nothing.

Both dry and real runs return the same report. Department ids in this example will differ from yours:

```json
{
  "rowsProcessed": 8,
  "unitsCreated": [
    "Engineering (/12/)",
    "Platform (/12/40/)",
    "Payments (/12/40/173/)",
    "Identity (/12/40/174/)",
    "Developer Experience (/12/41/)",
    "Data (/50/)",
    "Analytics Engineering (/50/51/)",
    "Product (/60/)",
    "Design Systems (/60/61/)"
  ],
  "membershipsCreated": 8,
  "membershipsUnchanged": 0,
  "personsCreated": ["ada.lovelace@example.com", "alan.turing@example.com"],
  "errors": [],
  "membershipsRemoved": [],
  "removalsSkipped": false
}
```

Read `errors` before doing anything else. Each entry carries the 1-based line number in your file (the header is line 1) and a human-readable reason, so a malformed roster tells you exactly which rows to fix.

When the report looks right, run the same command with `dryRun=false`.

To move a single person later, without preparing a file, you need the numeric id of the destination department. List the tree to find it:

```bash
curl "https://api.revenium.ai/profitstream/v2/api/departments?teamId=YOUR_TEAM_ID" \
  -H "x-api-key: YOUR_API_KEY"
```

Each entry carries its `id`, `name`, `parentId` and `path`. The path is a chain of department ids, such as `/12/40/173/`, rather than the names in your CSV. Find the department by name, confirm its parent chain using `parentId`, and read its `id` from that record. Then assign:

```bash
curl -X PUT "https://api.revenium.ai/profitstream/v2/api/departments/assignments?teamId=YOUR_TEAM_ID" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "ada.lovelace@example.com", "departmentId": 42}'
```

Listing requires an authenticated key; importing and assigning require an [API key with permission to manage the organization](/integrations/api-key-permissions.md).

***

### <i class="fa-folder-tree">:folder-tree:</i> Browsing Departments and Their People

In the **People and their departments** card under **Settings > Departments**, every department is listed with its headcount. A department's headcount includes the people in its sub-departments as well as the people listed directly under it. Open a department to page through its people. To find one person anywhere in the directory, use **Search everyone by email**.

### <i class="fa-arrows-left-right">:arrows-left-right:</i> Moving People in the App

People are assigned and moved in the **People and their departments** card under **Settings > Departments**. The same controls work for someone with no department and for someone you are moving.

To move one person:

1. Find the person's row. The **No department** filter narrows the list to people who still need one.
2. Select **Change**.
3. Pick the destination in **Choose department**.
4. Select **Save**. It stays disabled until you pick a department other than the current one.

To move several people at once, tick the checkbox on each row, or the checkbox on an email-domain group, or the checkbox whose label starts **Select all** and includes the number and scope shown. A bar appears with a **Department for the selection** picker. Its button reads **File N people** when nobody selected has a department, **Move N people** when everyone selected already has one, and **Set department for N people** for a mix. When the selection includes people who already have a department, the button asks for a second click to confirm the move.

A move made here follows the same rule as a CSV or API move: a person's first assignment covers their whole history, and a later move counts only from the moment you make it. See [When Someone Changes Departments](#when-someone-changes-departments).

***

### <i class="fa-gauge-high">:gauge-high:</i> Set a Budget or Guardrail for a Department

Use a department budget to watch one part of your organization's metered AI spend, or set a cap that stops matching requests when the limit is reached. First, set up departments and assign people so Revenium can attribute their usage.

1. Open **Guardrails** in the sidebar and select **New budget or alert**.
2. In **1. Watch**, choose **Metered**. Under **Filter data by (optional)**, select **Add filter**, choose **Department** as the dimension, select **Is**, and pick a department.
3. Choose the metric and budget condition, then select whether to notify, enforce a cap, or both. Review the rule before saving.

The selected department includes its sub-departments, which the form calls sub-teams: it says **Department budgets always include sub-teams.** There is no switch to exclude them.

To give every department its own limit with one rule, choose **Department** under **Group by (optional)** and leave the Department filter empty. Each department is compared with the full limit independently, rather than sharing one pool. A rule uses either a Department filter or Group by Department, not both.

{% hint style="info" %}
**Good to know.** Department choices appear for Metered rules. A user with read-only access can inspect Guardrails but cannot create or edit them. If your directory has no departments or assignments yet, the picker prompts you to set those up.
{% endhint %}

See [Set Budgets & Alerts](/track-and-control-costs/set-budgets-and-alerts.md) for the remaining Guardrail settings.

***

### <i class="fa-circle-question">:circle-question:</i> Where Unmapped Spend Appears

Spend that cannot be placed in a department is kept and shown in one of two buckets, so department totals never quietly lose money.

| Bucket                     | What it holds                                                                                                                                                    | How to clear it                                                                                                                                                                                                                                                         |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **No department assigned** | Spend from people Revenium can identify by email, who are not in any department. This includes people removed from their department, from the moment of removal. | Assign those people to a department. For someone who never had a department, all of their spend moves into it. For someone who was removed earlier, only spend from the moment you assign them moves. Spend from between the removal and the new assignment stays here. |
| **Unattributed**           | Spend that arrived without a developer's email, for example from a direct API key, a gateway or a proxy that sends no user identity.                             | Assigning departments does not change it. Send the developer's email with the usage so that new usage is attributed. Usage that already arrived without an email stays here.                                                                                            |

To see and fix the first bucket:

1. Open **AI Assistants** and the **Overview** tab. The page heading is **AI Assistants by Department**. **No department assigned** appears there as its own row, next to your departments.
2. When people with no department hold 5% or more of the spend, a notice on that page states how many there are and how much of the spend they hold. It appears only when you have created at least one department and no department filter is applied. If you cannot assign departments, select **Dismiss** to hide the notice for seven days while an admin updates the roster. Admins keep seeing it because they can assign the people whose spend is missing from department comparisons. Select **See who** to list them, or **Assign departments →** to go straight to the list in **Settings > Departments**. When the notice is not showing, use the **No department assigned** row instead.
3. In **People and their departments**, choose the **No department** filter and assign people as described in [Moving People in the App](#moving-people-in-the-app).

On the **By Employee** tab, choosing **Department** in **Group by:** shows the same **No department assigned** group.

Assigning departments requires an organization admin. Other users see the same counts and a **Copy request for your admin** button in place of **Assign departments →**.

***

### <i class="fa-clock-rotate-left">:clock-rotate-left:</i> Retroactive Attribution: The First Import Covers All History

**This is the most useful property of the design, and it is worth planning your rollout around.**

The first time a person is imported, their membership is backdated to the beginning of time rather than starting on the day you uploaded the file. Every event they have ever sent therefore attributes to the department you just assigned them.

In practice this means you can run the rollout in the order that is easiest for you:

1. Turn on metering and let developers work. Spend accumulates, attributed to individuals.
2. Weeks later, when you have your roster sorted out, upload the CSV.
3. Every historical report is now broken down by department, including the weeks before the file existed.

There is no backfill job, no reprocessing window, and no need to re-send telemetry. Because departments are resolved when the report runs, the data was never missing. Only the structure to interpret it was.

Backdating applies only to a person's **first** assignment. Once someone has a mapping, a normal move takes effect from the moment you make it and leaves their earlier history where it was, exactly as a transfer does. A developer imported into the wrong department and moved a week later therefore ends up with that week attributed to the wrong department and everything after it attributed correctly. To change earlier reports, see [Correct past departments](#correct-past-departments).

That is worth knowing before the first import rather than after it. Run a dry run, read the report, and confirm the tree is right while backdating is still available to you.

{% hint style="info" %}
**The one prerequisite.** Retroactive attribution works on the identity the events already carry, which is the developer's email address. Events that arrived without an email cannot be attributed later, because there is nothing to match the roster against. See [Identity must be an email address](#identity-must-be-an-email-address).
{% endhint %}

***

### <i class="fa-arrows-turn-right">:arrows-turn-right:</i> When Someone Changes Departments

When you reassign a person who already has a mapping, their **historical spend stays with their old department**. Only usage from the moment of the change forward counts against the new one.

This is deliberate, and it is usually the opposite of what people expect, so it is worth being explicit:

> A developer who spends six months on Payments and then moves to Identity leaves six months of spend on the Payments line. Identity's line starts at zero on the day of the move.

That is the correct behaviour for cost accounting. A department's reported spend for a past quarter should not change because somebody transferred afterwards; if it did, no closed period would ever stay closed and chargebacks already issued would stop reconciling.

The boundary is exact. An event landing precisely at the moment of a transfer counts against the **new** department.

Note the distinction from the previous section, since the two can look contradictory. A **first** assignment is backdated, because before it the person had no department at all and there is no history to protect. A **subsequent** change is not backdated, because there is history, and it belongs where it was earned.

The same rule holds everywhere a department appears, in the app and over the API: usage counts in the department the person was in when the usage happened, for whatever date range you select. A person who moved inside the range appears under both departments, each with the usage that happened there.

***

### <i class="fa-user-minus">:user-minus:</i> When Someone Leaves

Removing a person ends their department from that moment. **Their earlier spend stays on the department**, for the same reason a transfer leaves it there. Anything they are metered for afterwards shows under **No department assigned**.

In the app, find the person in **People and their departments** under **Settings > Departments**, select **Change**, then **Remove**, and confirm. Over the API:

```bash
curl -X DELETE "https://api.revenium.ai/profitstream/v2/api/departments/assignments?teamId=YOUR_TEAM_ID&email=ada.lovelace@example.com" \
  -H "x-api-key: YOUR_API_KEY"
```

To remove everyone who has left in one step, upload your whole roster as the full list: tick **This file is my full list** in the app, or add `removeMissing=true` to the import call. Everyone who is in a department and is not in the file is removed from it, keeping their earlier spend where it was. The dry run lists them in `membershipsRemoved` before anything is saved. Nobody is removed if any row in the file fails (`removalsSkipped` is then `true`), because a person whose row was rejected would otherwise look absent. Without the option, an import only adds and moves people and never removes anyone.

***

### <i class="fa-rotate-left">:rotate-left:</i> Redoing a Wrong Upload

Because a first assignment covers all of a person's history, and a later change does not, moving a wrongly uploaded person leaves their earlier spend on the wrong department. To correct that history with a CSV, see [Correct past departments](#correct-past-departments). You can also **delete** the person and upload them again. Deleting removes the person from your department list and takes **all** of their usage, past usage included, off the department; when they are uploaded again they are new, so their first assignment covers all of their history on the right department.

In the app, find the person in **People and their departments**, select **Change**, then **Delete**, and confirm. People who were uploaded and have never used AI are listed under their department too, so a wrong file can be cleaned up from the same screen. Over the API:

```bash
curl -X DELETE "https://api.revenium.ai/profitstream/v2/api/departments/persons?teamId=YOUR_TEAM_ID&email=ada.lovelace@example.com" \
  -H "x-api-key: YOUR_API_KEY"
```

Use Remove for someone who left, and Delete only to undo a mistake.

***

### <i class="fa-clock-rotate-left">:clock-rotate-left:</i> Correct past departments

A CSV correction changes which departments past usage appears under, so use it after a wrong upload or when a reorganization should appear in past reports.

1. Under **Settings > Departments**, open **Import departments from a CSV**, select **Upload a CSV**, and choose your file.
2. Under **What is this file?**, select **A correction of the past**. Choose **From the beginning** or **From a date** and, if needed, set the **Effective date**. Under **People who are not in this file**, choose **Leave them as they are**, **End their department today**, or **Erase their past**.
3. Select **Check file**. Review each person's **Before** and **After** departments and the past usage moving in the preview. When prompted, type the number of people the correction changes, then select **Import**.

**Import history** shows when each upload or correction ran, who ran it, the file and the number of people changed. To reverse an eligible correction, select **Undo**, then **Undo this correction**. Undo puts the affected people's department history back as it was before that import. A later change to the same people can prevent undo.

An organization admin can make corrections and undo imports.

***

### <i class="fa-circle-info">:circle-info:</i> Good to Know

#### Departments follow your directory when you schedule the API job

Each import reflects your roster at the moment it runs. To keep departments current as people join, move and leave, schedule the API job described in [Keep Departments in Sync Automatically](#keep-departments-in-sync-automatically). Anyone who starts using AI between runs appears under **No department assigned** until the next run, so that bucket also shows when the roster needs a refresh.

#### Ambiguous emails are never guessed

If more than one active directory person claims the same email address, the import does not choose between them. The row is reported as ambiguous and left for an administrator to resolve. This is intentional: silently picking one would attribute somebody's spend to the wrong person, and that error is invisible once it is made.

#### Only primary memberships

Each person belongs to exactly one department. Matrix organizations, dotted-line reporting, and splitting one developer's cost across two departments are not supported. A row that explicitly asks for a non-primary membership is reported as an error rather than being quietly ignored.

#### Identity must be an email address

Attribution matches on email at every stage. A fleet that reports only an opaque identifier and no email address cannot be mapped to a department, no matter how complete the roster is. Those events are still metered, still costed, and still attributed to your organization. In department rollups they appear under **Unattributed**.

Unattributed and No department assigned are real buckets, not dropped rows. When you group the By Employee table by department, its footer shows whether the rows reconcile with the filtered total and displays any gap. See [Where Unmapped Spend Appears](#where-unmapped-spend-appears).

If you are configuring Claude Code, the stock configuration already reports developer email correctly, and no extra attribution setup is needed for departments to work. See [Setup Claude Code](/track-and-control-costs/analyze-ai-tooling-spend/setup-claude-code.md).

#### Emails that match more than one person cannot be removed or deleted by email

Remove and Delete by email refuse, and change nothing, when the address matches more than one directory person. Resolve the duplicate first, or use the person's id.

***

### <i class="fa-file-export">:file-export:</i> Producing the CSV From Your Directory

Your IT administrators can generate the file directly from your existing directory. Both recipes below emit the exact two-column format the importer expects. Treat them as starting points: the attribute you use for the department path depends on how your directory is organized, and you will likely want to filter to the population that actually uses AI tooling.

#### Microsoft Entra ID (Azure AD)

Using the Microsoft Graph PowerShell SDK:

```powershell
Connect-MgGraph -Scopes "User.Read.All"

Get-MgUser -All -Property UserPrincipalName,Mail,Department,CompanyName,AccountEnabled |
  Where-Object { $_.AccountEnabled -and $_.Department -and $_.Mail } |
  Select-Object `
    @{Name = 'email';         Expression = { $_.Mail }},
    @{Name = 'department_path'; Expression = { $_.Department }} |
  Export-Csv -Path roster.csv -NoTypeInformation -Encoding utf8NoBOM
```

`Department` is a single value, which produces a flat one-level tree. To build a hierarchy, compose the path from whichever attributes carry your structure, for example a company or division and a department. Swap the `department_path` expression above for:

```powershell
@{Name = 'department_path'; Expression = { "$($_.CompanyName)/$($_.Department)" }}
```

Any attribute you reference in an expression must also appear in `-Property`, which is why `CompanyName` is requested above even though the flat version does not use it. Graph returns nothing for an unrequested attribute, and an empty leading segment would quietly collapse the hierarchy back to a flat tree rather than failing.

#### On-premises Active Directory

Using the ActiveDirectory module on a domain-joined machine:

```powershell
Import-Module ActiveDirectory

Get-ADUser -Filter { Enabled -eq $true } -Properties mail,department,division |
  Where-Object { $_.mail -and $_.department } |
  Select-Object `
    @{Name = 'email';         Expression = { $_.mail }},
    @{Name = 'department_path'; Expression = { if ($_.division) { "$($_.division)/$($_.department)" } else { $_.department } }} |
  Export-Csv -Path roster.csv -NoTypeInformation -Encoding utf8NoBOM
```

If your reporting structure is expressed through Active Directory organizational units (OUs) rather than a `department` attribute, derive the path from each user's distinguished name instead, reversing the OU components into a root-first path.

{% hint style="info" %}
**Check the file before uploading it.** Confirm the header is exactly `email,department_path` and that no value contains a stray comma. Then run the import with `dryRun=true` and read the error list. Between them, those two steps catch essentially every formatting problem before anything is written.
{% endhint %}

***

### <i class="fa-rotate">:rotate:</i> Keep Departments in Sync Automatically

You can keep departments in step with your directory automatically. Schedule a job on your side that exports your roster with one of the recipes above and sends it to the [import API](#importing-over-the-api-dry-run-first-then-for-real) with `removeMissing=true`. Each run adds new joiners, moves people who changed departments and removes people who left, with no one uploading a file by hand.

Anyone missing from the file has their department cleared, so the export must include everyone who should keep a department. The recipes above leave out people whose department is blank in your directory, so fill in their department there before the first run. Start with a dry run and confirm the removals it reports are people who left:

```bash
curl -X POST "https://api.revenium.ai/profitstream/v2/api/departments/import/csv?teamId=YOUR_TEAM_ID&removeMissing=true&dryRun=true" \
  -H "x-api-key: YOUR_API_KEY" \
  -F "file=@roster.csv"
```

Once the dry run shows only the changes you expect, remove `dryRun=true` from the job. Have the job read `errors` in each report: when any row fails, the run removes nobody and sets `removalsSkipped` to `true` (see [When Someone Leaves](#when-someone-leaves)).

Where the app offers to sync your directory, it opens **Settings > Departments**.

***

### <i class="fa-list-check">:list-check:</i> Rollout Checklist

1. Confirm your AI tooling reports developer **email**. Everything else depends on it.
2. Export a roster from your directory using one of the recipes above.
3. Check the file first (**Check file** in the app, or `dryRun=true`) and resolve every entry in `errors`.
4. Import for real, then check `unitsCreated` matches the structure you intended.
5. Group cost reports by department, in the app or over the API. Historical periods are included automatically.
6. Check **No department assigned** on the **AI Assistants by Department** page and assign anyone listed.
7. Schedule the API job in [Keep Departments in Sync Automatically](#keep-departments-in-sync-automatically) so departments follow your directory as people join, move and leave.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.revenium.io/track-and-control-costs/attribute-spend-to-departments.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
