> 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-teams.md).

# Attribute Spend to Teams & Departments

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

Org units are Revenium's answer. You describe your reporting structure once, map each person to a unit, 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.

{% hint style="warning" %}
**Availability.** Org-unit attribution is enabled per tenant rather than being on by default. If the endpoints below return `403 Feature not available`, it is not yet switched on for your account — contact Revenium to have it enabled. Per-team rollups are currently available through the API; a dimension selector in the UI is still to come. See **Current Limits** below.
{% endhint %}

***

### <i class="fa-sitemap">:sitemap:</i> What an Org Unit Is

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

Each person is assigned to exactly one unit as their **primary** membership, and that assignment is dated. Because the whole tree carries a path, a rollup at `Engineering` includes every descendant beneath it, so you can look at one department or drill into a single squad 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.** Org-unit people 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 Teams** below possible.

***

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

Bulk import takes a CSV with this header:

```
email,org_unit_path
```

An optional third column, `is_primary`, is accepted. Columns are read **by name**, so their order in the file does not matter.

| Column          | Required | Meaning                                                                                    |
| --------------- | -------- | ------------------------------------------------------------------------------------------ |
| `email`         | Yes      | The developer's email address, matching the identity their tooling reports.                |
| `org_unit_path` | Yes      | Slash-separated unit **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,org_unit_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 unit 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 unit it names is reported as unchanged rather than written again, so re-uploading a slightly edited roster does not duplicate anything.

{% hint style="info" %}
**File limits.** Uploads are capped at 5 MB, and there is a maximum row count. A file that exceeds either is rejected with a clear error rather than partially applied. For a roster of a few thousand developers, neither limit is a practical concern.
{% endhint %}

***

### <i class="fa-play">:play:</i> Importing: 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/org-units/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 unit 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:

```json
{
  "rowsProcessed": 8,
  "unitsCreated": [
    "Engineering (Engineering)",
    "Platform (Engineering/Platform)",
    "Payments (Engineering/Platform/Payments)",
    "Identity (Engineering/Platform/Identity)",
    "Developer Experience (Engineering/Developer Experience)",
    "Data (Data)",
    "Analytics Engineering (Data/Analytics Engineering)",
    "Product (Product)",
    "Design Systems (Product/Design Systems)"
  ],
  "membershipsCreated": 8,
  "membershipsUnchanged": 0,
  "personsCreated": ["ada.lovelace@example.com", "alan.turing@example.com"],
  "errors": []
}
```

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 unit. List the tree to find it:

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

Each entry carries its `id`, `name`, `parentId` and full `path`, so you can match on the path you used in the CSV and read the id from the same record. Then assign:

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

Listing requires an authenticated key; importing and assigning require an API key with permission to manage the organization.

***

### <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 unit 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, changing it takes effect from the moment you change it and leaves their earlier history where it was, exactly as a transfer does. A developer imported into the wrong unit and corrected a week later therefore ends up with that week attributed to the wrong department and everything after it attributed correctly, rather than the whole history moving.

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 Teams

When you reassign a person who already has a mapping, their **historical spend stays with their old team**. 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** unit.

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.

***

### <i class="fa-triangle-exclamation">:triangle-exclamation:</i> Current Limits

These are real constraints today, not caveats about edge cases.

#### The CSV is a point-in-time snapshot

Nothing keeps the tree in step with your directory. Joiners, leavers and transfers are reflected only when somebody uploads a new file. For an organization with normal churn this is a recurring task, not a one-off. Until automated sync ships (see below), plan for a monthly or quarterly re-upload, and treat it as an operational job with an owner.

#### 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 unit. 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 team, no matter how complete the roster is. Those events are still metered, still costed, and still attributed to your organization — they simply fall into the **Unassigned** bucket for department rollups.

Unassigned is a real bucket, not a dropped row. Totals grouped by department always reconcile against the ungrouped total, so a gap in your roster shows up as a visible Unassigned figure rather than as quietly missing money.

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

#### Rollups are API-only for now

The department dimension is served by the reporting API, but the UI does not yet offer it as a grouping option. Until it does, per-team rollups are retrieved programmatically. Everything else described on this page — building the tree, importing rosters, reassigning people — works today.

***

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

Your IT team 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 = 'org_unit_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 `org_unit_path` expression above for:

```powershell
@{Name = 'org_unit_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 = 'org_unit_path'; Expression = { if ($_.division) { "$($_.division)/$($_.department)" } else { $_.department } }} |
  Export-Csv -Path roster.csv -NoTypeInformation -Encoding utf8NoBOM
```

If your reporting structure is expressed through the directory's own organizational-unit hierarchy 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="warning" %}
**Windows PowerShell 5.1 and the byte-order mark.** `-Encoding utf8NoBOM` requires PowerShell 6 or later. In Windows PowerShell 5.1, `-Encoding UTF8` writes a byte-order mark at the start of the file, which becomes part of the first column name. Because the importer matches columns **by name**, a file whose header reads as `<BOM>email` will not be recognised. Either run the export in PowerShell 7, or strip the mark afterwards:

```powershell
$csv = Get-Content roster.csv -Raw
[System.IO.File]::WriteAllText(
  (Resolve-Path roster.csv),
  $csv,
  (New-Object System.Text.UTF8Encoding $false)
)
```

{% endhint %}

{% hint style="info" %}
**Check the file before uploading it.** Confirm the header is exactly `email,org_unit_path`, that no value contains a stray comma, and that the file carries no byte-order mark. 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> Automated Directory Sync

**Automated directory synchronization is not available yet.** CSV import and the assignment endpoints are the supported ways to populate and maintain the tree today. If you have been told otherwise, that is the correct position as of this page's publication.

When it ships it will work as a scheduled pull from Revenium to your directory, on a daily incremental and weekly full reconciliation cycle, rather than as a push from you to us. Connecting it will require:

* **Microsoft Entra ID** — an application registration in your tenant with directory read permission over users and group memberships, administrator consent, and a client credential shared with Revenium.
* **On-premises Active Directory** — synchronization into Entra ID first, since a cloud service cannot read an on-premises directory directly. Organizations already running Entra Connect are covered by the row above.
* **Other providers** — Okta, Google Workspace and HR-system sources are planned behind the same mechanism, each with its own credential model.

The shape of the integration therefore depends on how your directory is run, which is worth establishing early if automated sync matters to your rollout. In the meantime a scheduled export from the recipes above, uploaded on a regular cadence, achieves the same result with an owner attached.

***

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

1. Confirm org-unit attribution is enabled for your tenant. The endpoints return `403` if it is not.
2. Confirm your AI tooling reports developer **email**. Everything else depends on it.
3. Export a roster from your directory using one of the recipes above.
4. Import with `dryRun=true` and resolve every entry in `errors`.
5. Import for real, then check `unitsCreated` matches the structure you intended.
6. Query cost reports grouped by department. Historical periods are included automatically.
7. Assign an owner and a cadence for re-uploading the roster as your organization changes.


---

# 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 dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

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

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

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.
