# Management API

Use the management API to control your organization's keys, guardrails, grants, presets,
workspaces, notifications and exports. It also reports usage, spend forecasts, savings, security
and audit data.

Every route is served under `/v1/...` and under `/api/v1/...`. The two paths act the same.

```bash
curl https://demo-api.routerplus.com/v1/keys \
  -H "Authorization: Bearer $TM_MANAGEMENT_KEY"
```

## Authentication

Send a management key as a Bearer token:

```
Authorization: Bearer tm_mk_...
```

- A management key belongs to one organization. Every route acts on that organization only. An
  id from another organization returns `404`.
- A management key never authenticates an inference request. An API key (`tm_vk_...`) never
  authenticates a management route, with two exceptions:
  - [`GET /v1/key`](#key-status-api-key) returns the calling API key's own status.
  - An API key can request grants for itself and read or cancel them. See [Grants](#grants).
- A missing, disabled or expired management key returns `401`.

## Requests and responses

- Send JSON with `content-type: application/json`. A request body is at most 256 KiB.
- One object comes back as `{"data": {...}}`. A list comes back as `{"data": [...]}`.
- Money is in USD. Times are ISO 8601 in UTC. Ids are UUIDs.
- The [audit log](#audit-log) records every write, with the management key's prefix as the actor.

### Errors

```json
{"error": {"code": 400, "message": "limit: must be a number of USD from 0 to 10,000,000", "type": "invalid_request"}}
```

`code` is the HTTP status. `type` is a stable string you can match on.

| Status | Meaning |
|---|---|
| 400 | The input is not valid. `message` names the field. |
| 401 | No valid key. |
| 403 | The key is valid but cannot do this. For example, an API key cannot approve a grant. |
| 404 | No such record in your organization, or no such route. |
| 405 | The route does not take this method. |
| 409 | A conflict: a record exists, a version is stale, or the record is in the wrong state. |
| 413 | The body is larger than 256 KiB. |

## Key status (API key)

| Route | Does |
|---|---|
| `GET /v1/key` | Returns the calling API key's own status. Authenticate with `Authorization: Bearer tm_vk_...`. |

```json
{"data": {"label": "ci", "usage": 1.25, "limit": 10, "limit_remaining": 8.75, "limit_reset": "monthly",
  "expires_at": null, "is_free_tier": false, "rate_limit": {"requests": 300, "interval": "1m"}, "include_byok_in_limit": false}}
```

- `usage` is the spend in the current limit period. With no `limit_reset`, it is the spend over
  the key's life.
- While an active [grant](#grants) raises the key's limit, `limit` is the granted value. The
  answer then also has `base_limit` (the key's own limit) and `grant_id`.
- An expired key returns `401 key_expired`.

## API keys

| Route | Does |
|---|---|
| `GET /v1/keys` | Lists active keys. Query: `workspace_id`, `include_disabled=true`. |
| `POST /v1/keys` | Creates a key. Returns `201` with `key` (the raw key) and `data`. |
| `GET /v1/keys/:id` | Reads one key. |
| `PATCH /v1/keys/:id` | Changes the fields you send. |
| `DELETE /v1/keys/:id` | Disables the key. It stops working at once. |
| `POST /v1/keys/:id/rotate` | Creates a new key with the same settings. |

The create and rotate responses show the raw key once. Store it then. You cannot read it again.

### Create fields

| Field | Type | Notes |
|---|---|---|
| `name` | string | Required. 1 to 80 characters. |
| `workspace_id` | UUID | Default: the default workspace. |
| `principal_id` | UUID | Default: the workspace's service principal. |
| `limit` | number or null | USD per `limit_reset` period. |
| `limit_reset` | string or null | `daily`, `weekly`, `monthly`, or null for no reset. |
| `include_byok_in_limit` | boolean | Count BYOK inference cost in the limit. |
| `expires_at` | ISO time or null | Must be in the future. The key stops working then. |
| `ip_allowlist` | array or null | CIDR ranges or addresses. Null allows any address. |
| `labels` | object | At most 20. Keys: `a-z 0-9 _ . -`, 1 to 40 characters. Values: at most 200 characters. |
| `finance_category` | string or null | 1 to 64 characters. |

### Update

`PATCH` takes `name`, `disabled`, and every create field except `workspace_id` and
`principal_id`. Send `null` to clear `limit`, `expires_at`, `ip_allowlist` or
`finance_category`.

### Rotate

Rotate takes `{"grace_seconds": 0..604800}` (default 0).

- With 0, the old key is disabled at once. Otherwise the old key expires after the grace period.
- The new key has `rotated_from` set to the old key's id.
- A disabled or expired key cannot be rotated (`409`).

### Key object

```json
{"key_id": "...", "name": "ci", "key_prefix": "tm_vk_3f9a1c", "workspace_id": "...", "principal_id": "...",
 "disabled": false, "limit": 10, "base_limit": 10, "grant_id": null, "limit_reset": "monthly", "limit_remaining": 8.75,
 "usage": 1.25, "include_byok_in_limit": false, "expires_at": null, "ip_allowlist": ["10.0.0.0/8"], "labels": {"team": "core"},
 "finance_category": null, "rpm_limit": 300, "created_at": "...", "created_via": "management_api",
 "rotated_from": null, "last_used_at": "..."}
```

While an active grant raises the key's limit, `limit` and `limit_remaining` use the granted
value. `base_limit` is the key's own limit and `grant_id` names the grant. With no grant,
`base_limit` equals `limit` and `grant_id` is null.

## Management keys

| Route | Does |
|---|---|
| `GET /v1/management-keys` | Lists management keys. |
| `POST /v1/management-keys` | `{name, expires_at?}`. Returns `201` with `key` (shown once). |
| `DELETE /v1/management-keys/:id` | Disables the key. A key cannot delete itself (`400 cannot_delete_self`). |

## Workload identities

A workload identity lets a CI job or cloud workload trade its OIDC token for a short-lived API
key.

| Route | Does |
|---|---|
| `GET /v1/workload-identities` | Lists identities. |
| `POST /v1/workload-identities` | Creates one. Returns `201`. |
| `DELETE /v1/workload-identities/:id` | Deletes one. |

| Field | Notes |
|---|---|
| `name` | Required. 1 to 100 characters. |
| `workspace_id` | Required. |
| `principal_id` | Default: the workspace's service principal. |
| `issuer` | An https URL, such as `https://token.actions.githubusercontent.com`. |
| `audience` | The token's `aud`. |
| `subject_pattern` | The token's `sub`. `*` matches any run of characters. |
| `required_claims` | Claim names and the exact values they must have. At most 20. |
| `key_ttl_seconds` | 300 to 86,400. Default 3,600. |

## Guardrails

A guardrail is a set of rules. It applies only when you assign it to the org, a workspace, a
member or a key. When several guardrails apply to a request, the strictest rule wins.

| Route | Does |
|---|---|
| `GET /v1/guardrails` | Lists guardrails with their assignments. |
| `POST /v1/guardrails` | `{name, description?, ...rules}`. Returns `201`. |
| `GET /v1/guardrails/:id` | Reads one guardrail. |
| `PATCH /v1/guardrails/:id` | Replaces the rules you send. `version` goes up by 1. |
| `DELETE /v1/guardrails/:id` | Deletes the guardrail and its assignments. |
| `POST /v1/guardrails/:id/assignments` | `{target_kind, target_id}`. Returns `201`. |
| `DELETE /v1/guardrails/:id/assignments/:kind/:target` | Removes one assignment. URL-encode an email. |
| `GET /v1/guardrails/:id/status` | Spend of each budget in its current period. |
| `GET /v1/guardrails/events` | Requests a guardrail blocked, redacted or flagged. |

### Rules

| Field | Type |
|---|---|
| `allowed_models`, `blocked_models` | Model patterns. `*` matches any run of characters. |
| `allowed_providers`, `blocked_providers` | Provider names. |
| `zdr` | boolean. Only providers that keep no data. |
| `data_collection` | `"deny"`: only providers that do not train on data. |
| `allowed_regions` | Region codes such as `us`, `eu`, `in`. |
| `blocked_infrastructure` | Clouds such as `aws`, `gcp`. |
| `budgets` | Up to 20 of `{id?, limit_usd, period, model?, provider?, include_byok?, shared?}`. `period`: `day`, `week`, `month`, `lifetime`. `shared: true` makes one budget for every target of the guardrail. |
| `limit_usd`, `reset_interval`, `include_byok_in_budgets` | One budget, in the short form. |
| `rpm`, `tpm`, `max_output_tokens` | Integers. |
| `server_tools` | `allow` or `block`. |
| `content_rules` | Up to 50 of `{id?, kind, preset?, pattern?, flags?, action, label?}`. `kind`: `pii`, `regex`, `injection`. `action`: `redact`, `block`, `flag`. |
| `on_scan_limit` | `allow` or `block`, for text too long to scan in full. |
| `overridable` | boolean. Default `true`. `false` makes the guardrail a hard limit. See [Hard limits](#hard-limits). |

An unknown field returns `400 invalid_guardrail`. A regex that can run too long returns
`400 invalid_regex_pattern`.

### Assignments

| `target_kind` | `target_id` |
|---|---|
| `org` | Your org id, or leave it out. |
| `workspace` | A workspace id. |
| `member` | A member's email. |
| `key` | An API key id. |

A target has at most one guardrail. A new assignment on a target replaces the old one. The
response names the replaced guardrail in `replaced_guardrail_id`.

### Status

A status entry:

```json
{"budget_id": "b0", "budget_key": "...", "target_kind": "key", "target_id": "...", "shared": false, "model": null, "provider": null,
 "include_byok": false, "limit_usd": 5, "base_limit_usd": 5, "grant_id": null, "spent_usd": 1.25, "remaining_usd": 3.75,
 "period": "day", "period_start": "2026-10-31T00:00:00.000Z", "resets_at": "2026-11-01T00:00:00.000Z"}
```

`limit_usd` is the limit RouterPlus enforces now. `base_limit_usd` is the guardrail's own limit.
While an active grant raises the budget, `grant_id` names it and `limit_usd` is the granted value.

### Events

| Query | Notes |
|---|---|
| `since`, `until` | Time range. |
| `action` | `blocked`, `redacted` or `flagged`. |
| `key_id` | One key. |
| `limit` | At most 500. Default 100. |

Events come newest first. They never hold the matched text.

## Grants

A grant is a time-boxed exception to a guardrail budget, an API key's limit, or a guardrail's
model rules. A member or an API key requests a grant. An admin approves or rejects it. An
approved grant applies from `starts_at` until `expires_at`. After that, the base value applies
again.

In the console, open Access > Grants.

```bash
curl https://demo-api.routerplus.com/v1/grants \
  -H "Authorization: Bearer $TM_MANAGEMENT_KEY" \
  -H "content-type: application/json" \
  -d '{"target": {"type": "key_limit", "key_id": "8c0d..."},
       "change": {"limit_usd": 50},
       "expires_at": "2026-11-09T00:00:00Z",
       "reason": "Load test for the launch"}'
```

| Route | Does | Who may call |
|---|---|---|
| `GET /v1/grants` | Lists grants, newest first. | Management key. An API key sees only the grants it requested. |
| `POST /v1/grants` | Requests a grant. Returns `201` with the grant in `pending` status. | Management key, or an API key for itself. |
| `GET /v1/grants/:id` | Reads one grant with its `events`. | Management key, or the API key that requested it. |
| `POST /v1/grants/:id/approve` | `{note?}`. Approves a pending grant. | Management key only. An API key gets `403`. |
| `POST /v1/grants/:id/reject` | `{note?}`. Rejects a pending grant. | Management key only. An API key gets `403`. |
| `POST /v1/grants/:id/cancel` | `{note?}`. Ends a pending or approved grant before `expires_at`. | Management key, or the API key that requested it. |

`note` is optional, at most 500 characters. An approve or cancel applies to the next request on
every gateway.

### Request fields

| Field | Notes |
|---|---|
| `target` | Required. What the grant changes. See the table below. |
| `change` | Required. `{"limit_usd": n}` for `budget` and `key_limit` grants. `{"allow_model": "<id or pattern>"}` for `guardrail` grants. |
| `starts_at` | ISO time. Default: now. It cannot be in the past (5 minutes of clock skew is allowed). |
| `expires_at` | Required. ISO time after now and after `starts_at`. At most 90 days after `starts_at`. |
| `reason` | Required. 1 to 500 characters. |
| `requested_by` | Management key only. The email of an active member to record as the requester. |

`target`:

| `type` | Fields | Changes |
|---|---|---|
| `budget` | `guardrail_id`, `budget_id`, and optionally `target_kind` and `target_id` | Raises one budget of a guardrail to `change.limit_usd`. With `target_kind` and `target_id`, the grant raises the budget for that assignment only. Without them, it raises it for every assignment. A shared budget takes no target. |
| `key_limit` | `key_id` | Raises an API key's limit to `change.limit_usd`. The key must have a limit. |
| `guardrail` | `guardrail_id`, and optionally `target_kind` and `target_id` | Lets `change.allow_model` pass the guardrail's allowed and blocked model lists. With a target, it covers only the keys under that target. Every other guardrail still applies. |

`target_kind` is `org`, `workspace`, `member` or `key`, as for [assignments](#assignments).

Rules for limits:

- `change.limit_usd` is a number of USD from 0 to 10,000,000. It must be more than the current
  limit.
- When several active grants raise the same limit, the highest one applies.
- A granted limit never goes below the base limit and never above the org's
  [hard limit](#hard-limits) for that period.

What an API key may request:

- A `key_limit` grant for its own key only.
- A `budget` grant only on a guardrail that applies to the key. Without a target, the grant
  raises the budget of the most specific assignment that covers the key.
- A `guardrail` grant only on a guardrail that applies to the key. The grant covers that key only.
- An API key cannot set `requested_by`.

### Grant object

```json
{"data": {"id": "...", "status": "approved", "state": "active",
  "target": {"type": "budget", "guardrail_id": "...", "budget_id": "b0", "target_kind": "workspace", "target_id": "..."},
  "change": {"limit_usd": 50}, "base_limit_usd": 20, "workspace_id": "...",
  "starts_at": "...", "expires_at": "...", "reason": "Load test for the launch",
  "requested_by": "key:...", "approved_by": "tm_mk_...", "decided_by": "tm_mk_...", "decided_at": "...",
  "decision_note": null, "cancelled_by": null, "cancelled_at": null, "created_at": "...", "updated_at": "..."}}
```

- `status` is `pending`, `approved`, `rejected` or `cancelled`.
- `state` adds the time window: `pending`, `scheduled` (approved, not started), `active`,
  `expired`, `rejected` or `cancelled`.
- `base_limit_usd` is the limit when the grant was requested. It is null for `guardrail` grants.
- `GET /v1/grants/:id` adds `events`: each state change with `action`, `actor`, `detail` and
  `created_at`.

### List query

| Query | Notes |
|---|---|
| `status` | `pending`, `approved`, `rejected` or `cancelled`. |
| `state` | `pending`, `scheduled`, `active`, `expired`, `rejected` or `cancelled`. |
| `target_type` | `budget`, `key_limit` or `guardrail`. |
| `key_id` | Grants on that key's limit, or guardrail grants scoped to that key. |
| `guardrail_id` | Grants on one guardrail. |
| `limit` | At most 500. Default 100. |

### Where grants show

- A response to a request under active grants has the header `x-tm-grants`, a comma-separated
  list of grant ids. A refusal under active grants has it too.
- [Guardrail status](#status) shows `limit_usd`, `base_limit_usd` and `grant_id` per budget.
- [Key reads](#key-object) and [`GET /v1/key`](#key-status-api-key) show the granted `limit`,
  `base_limit` and `grant_id`.
- Budget alerts use the granted limits.

### Notices

RouterPlus sends a notice to the org's enabled [notification channels](#notification-channels)
and by email when:

- a grant is requested (to org owners and admins, and admins of the grant's workspace),
- a grant is approved, rejected or cancelled (to the requester),
- an approved grant ends in less than a day (to the requester and the approver).

### Hard limits

A guardrail with `"overridable": false` is a hard limit.

- It takes no grant. A request for a grant on it returns `409 hard_limit`.
- When it is assigned to the org, each of its budgets with no `model` or `provider` caps every
  grant that raises a budget or key limit with the same period. A raise above the cap returns
  `409 hard_limit`.
- Approval checks the target again. A grant whose guardrail became a hard limit, or whose raise
  is now above the cap, is refused with `409 hard_limit`.

### Grant errors

| Status and type | When |
|---|---|
| `400 invalid_expiry` | `expires_at` is not after now and after `starts_at`. |
| `400 grant_too_long` | The grant lasts more than 90 days. |
| `400 grant_not_raise` | `change.limit_usd` is not more than the current limit. |
| `403 forbidden` | An API key tried to approve or reject, set `requested_by`, or request a grant outside its own key. |
| `404` | The guardrail, budget, assignment, key, member or grant is not in your org. |
| `409 hard_limit` | The guardrail is a hard limit, or the raise is above the org's hard limit. |
| `409 no_limit` | The key has no limit to raise. |
| `409 grant_not_pending` | Approve or reject on a grant that is not pending. |
| `409 grant_not_open` | Cancel on a grant that is rejected or cancelled. |
| `409 grant_expired` | The grant's `expires_at` has passed. |

## Presets

A preset is a versioned request template. Call it with `"model": "@preset/<slug>"` or
`"@preset/<slug>@<version>"`.

| Route | Does |
|---|---|
| `GET /v1/presets` | Lists the latest version of each preset. Query: `workspace_id`. |
| `POST /v1/presets` | `{slug, description?, workspace_id?, template}`. Creates version 1. |
| `GET /v1/presets/:slug` | The latest version. Query `version=N` for an older one. |
| `PUT /v1/presets/:slug` | `{template, description?, expected_version}`. Creates the next version. |
| `DELETE /v1/presets/:slug` | Deletes every version. |

- A slug is 1 to 80 of `a-z 0-9 _ -`, starting with a letter or digit.
- `POST` returns `409 preset_exists` when the slug is in use.
- `PUT` returns `409 version_conflict` when `expected_version` is not the latest version.

### Template

| Field | Type |
|---|---|
| `model` or `models` | Send one, not both. `models` lists 1 to 5 models, tried in order. |
| `provider` | A provider routing object, as in a request. |
| `defaults` | Parameters used when the request does not set them. |
| `overrides` | Parameters that replace the request's values. |
| `system_prompt` | Up to 20,000 characters. Put before the request's own system text. |
| `guardrail_id` | A guardrail in your org. |

Parameters in `defaults` and `overrides`:

| Parameter | Range |
|---|---|
| `temperature` | 0 to 2 |
| `top_p` | 0 to 1 |
| `top_k` | 1 to 32768 |
| `max_tokens`, `max_completion_tokens` | 1 to 1,000,000 |
| `frequency_penalty`, `presence_penalty` | -2 to 2 |
| `seed` | integer |
| `stop` | a string or up to 4 strings |
| `reasoning_effort` | `minimal`, `low`, `medium`, `high`, `xhigh` |
| `response_format` | object |
| `parallel_tool_calls`, `stream` | boolean |

### How a preset combines with a request

1. `overrides` beat the request. The request beats `defaults`.
2. The template's `model` or `models` replace the request's.
3. A request's `provider` object can only narrow the template's. `only` keeps the providers in
   both. `ignore` adds. `allow_fallbacks: false`, `zdr: true` and `data_collection: "deny"`
   always win. The request's `sort` and `order` apply only when the template sets none.
4. On `/v1/messages`, a preset sets `temperature`, `top_p`, `top_k`, `max_tokens`, `stop` (as
   `stop_sequences`) and `stream`. The other parameters apply on the OpenAI surface only.

## Workspaces

| Route | Does |
|---|---|
| `GET /v1/workspaces` | Lists workspaces. |
| `POST /v1/workspaces` | `{name, finance_category?, default_sort?}`. Returns `201`. |
| `PATCH /v1/workspaces/:id` | `name`, `disabled`, `finance_category`, `default_sort`, `route_policy_id`. |
| `POST /v1/workspaces/:id/clone` | `{name, include}`. Returns `201`. |
| `GET /v1/workspaces/:id/members` | Lists workspace members. |
| `PUT /v1/workspaces/:id/members` | `{email, role}`. Adds a member or changes the role. |
| `GET /v1/workspaces/:id/members/:email` | Reads one member. |
| `PUT /v1/workspaces/:id/members/:email` | `{role}`. |
| `DELETE /v1/workspaces/:id/members/:email` | Removes a member. |

- `default_sort`: `routing_cost`, `price`, `throughput` or `latency`.
- `role`: `admin`, `member` or `viewer`.
- The email must belong to a member of the organization.

### Clone

A clone gets the source's route policy, finance category and default sort. `include` can list:

- `guardrails`: the source workspace's guardrail assignment.
- `presets`: the presets scoped to the source. Each copy gets the slug `<slug>-copy`.
- `members`: the workspace members.

## Organization settings

| Route | Does |
|---|---|
| `GET /v1/org` | Returns `org_id`, `name`, `default_sort`, `request_log`, `request_log_until`, `retention_days` and `hipaa_mode`. |
| `PATCH /v1/org` | Changes the fields below. |

| Field | Notes |
|---|---|
| `default_sort` | As for workspaces, or null. |
| `request_log` | `on` or `off`. With `on`, RouterPlus logs full requests for `request_log_days` (1 to 365, default 30). In HIPAA mode, `on` returns `409 hipaa_mode`. |
| `retention_days` | 1 to 3650, or null for the defaults. See [Retention](#retention). |
| `hipaa_mode` | `true` turns HIPAA mode on at once. `false` turns it off only with `"confirm": "turn off hipaa mode"`. Without it the answer is `409 confirmation_required`. See [HIPAA mode](#hipaa-mode). |

Every change writes an audit row (`GET /v1/audit`, action `org.update`). Audit rows cannot be
changed or deleted.

### Retention

RouterPlus deletes stored records past their limit. A job runs every 30 minutes and deletes at
most 5,000 rows per table per run.

| Records | Default limit |
|---|---|
| Request labels and sessions | 400 days |
| Guardrail events | 400 days |
| MCP tool call records | 400 days |
| Eval answers and judge results | 90 days after the eval finished |
| Batch files | At their `expires_at` |
| Slack agent thread memory | Emptied 7 days after the last message, deleted 7 days later |
| OAuth codes and SSO sign-in states | 1 day after they expire |
| Sign-in log | 400 days |

`retention_days` can only make your org's limits shorter. RouterPlus uses the smaller of the
default and `retention_days`.

- For eval results, it counts from when the eval finished.
- For batch files, it counts from their creation.
- For Slack threads, it counts from their last message.
- The sign-in log, OAuth codes and SSO states keep the defaults.

### HIPAA mode

With `hipaa_mode` on:

- Every request gets an implicit org guardrail: `zdr: true`, `data_collection: "deny"`,
  `server_tools: "block"`. Its id in `x-tm-guardrails` is `00000000-0000-4000-8000-000000000074`.
  RouterPlus refuses a model with no route that meets it.
- RouterPlus does not log requests. The org's `request_log` setting and any batch setting do not
  apply.
- The response cache and the semantic cache are off. A request that asks for the cache gets an
  answer with `x-tm-cache: skip`.
- A broadcast destination with `privacy.include_content: true` returns `409 hipaa_mode`.
  RouterPlus does not send to a content destination made before HIPAA mode.
- Slack agents keep no thread memory. Each answer uses only the current message. Turning HIPAA
  mode on empties the stored memory.

HIPAA mode is a set of data rules. It is not a business associate agreement.

## Notification channels

Channels receive budget alerts when spend crosses a threshold, and [grant notices](#notices).

| Route | Does |
|---|---|
| `GET /v1/notifications/channels` | Lists channels. |
| `POST /v1/notifications/channels` | `{kind, config, enabled?, thresholds?}`. Returns `201`. |
| `PATCH /v1/notifications/channels/:id` | Changes the fields you send. `kind` cannot change. |
| `DELETE /v1/notifications/channels/:id` | Deletes the channel. |
| `POST /v1/notifications/channels/:id/test` | Sends a sample alert. Returns `{ok, status?, error?}`. |

| `kind` | `config` |
|---|---|
| `email` | `{to: [1 to 10 addresses]}` |
| `slack` | `{webhook_url: "https://hooks.slack.com/..."}` |
| `webhook` | `{url: "https://...", secret?}`. With a secret, RouterPlus signs each request in the `x-routerplus-signature` header. |

- `thresholds` are percents of a budget, integers from 1 to 100. Default: `[50, 80, 100]`.
- Each threshold is sent once per budget period. A channel gets one alert per budget, for the
  highest threshold crossed.
- Alerts cover guardrail budgets and API key limits.
- The Slack URL and the webhook secret come back as `"__secret__"`. Send `"__secret__"` to keep
  the stored value.

## Broadcast destinations

Destinations receive a trace of each request.

| Route | Does |
|---|---|
| `GET /v1/broadcast/destinations` | Lists destinations. |
| `POST /v1/broadcast/destinations` | `{name?, kind, config, enabled?, sampling?, privacy?, filter?}`. Returns `201`. |
| `PATCH /v1/broadcast/destinations/:id` | Changes the fields you send. `kind` cannot change. |
| `DELETE /v1/broadcast/destinations/:id` | Deletes the destination. |
| `POST /v1/broadcast/destinations/:id/test` | Sends one sample trace. Returns `{ok, status}`. |

| `kind` | `config` |
|---|---|
| `webhook` | `{url, secret?, headers?}` |
| `otlp` | `{endpoint, headers?, service_name?}` |
| `langfuse` | `{public_key, secret_key, host?}` |
| `datadog` | `{api_key, site?, service?}` |
| `clickhouse` | `{url, table, database?, user?, password?}` |
| `s3` | `{bucket, region, access_key_id, secret_access_key, prefix?, endpoint?, session_token?, gzip?}`. Also R2 and MinIO. |
| `azure_blob` | `{account, container, sas_token or account_key, prefix?, gzip?}` |
| `bigquery` | `{project_id, dataset, table, service_account_json}` |
| `snowflake` | `{account, user, private_key_pem, database, schema, table, warehouse?, role?}` |

[Broadcast to storage and warehouses](broadcast.md) gives the object layout and the table DDL for
the storage and warehouse kinds.

| Field | Notes |
|---|---|
| `sampling` | 0 to 1. Default 1. |
| `privacy` | `{include_content, omit_fields, hash_fields, redact_paths}`. Content is left out by default. In HIPAA mode, `include_content: true` is refused. |
| `filter` | `{workspace_ids, key_ids, models, errors_only}`. |

Secrets and header values never come back. They show as `"__secret__"`. Send `"__secret__"` to
keep the stored value.

## BYOK connections

| Route | Does |
|---|---|
| `PATCH /v1/connections/:id` | `{"negotiated_discount_percent": 0..100}` sets the discount your provider gives you on a BYOK connection. Send `null` to clear it. |

Cost reports use the discount (see [`GET /v1/activity/byok`](#activity)).

## Activity

| Route | Does |
|---|---|
| `GET /v1/activity` | Usage per group. |
| `GET /v1/activity/labels?label=<key>` | Requests grouped by the value of one request label. |
| `GET /v1/activity/sessions` | Requests grouped by session id. |
| `GET /v1/activity/cache` | Prompt caching per model and provider. |
| `GET /v1/activity/cache/advice` | What in your traffic costs cache reuse, with the numbers and the fix. |
| `GET /v1/activity/byok` | Requests on your own provider connections, per connection and model. |
| `GET /v1/activity/forecast` | Projected spend to the end of the month or quarter. See [Spend forecast](#spend-forecast). |
| `POST /v1/activity/forecast` | The same forecast, with the parameters in the body. |
| `GET /v1/activity/savings` | What your traffic would cost on cheaper routes or models. See [Savings](#savings). |

`GET /v1/activity` takes:

| Query | Notes |
|---|---|
| `from`, `to` | UTC dates, both included. Default: the last 30 days. At most 366 days. |
| `group_by` | `day` (default), `model`, `provider`, `key`, `workspace`, `member`, `finance_category`. |
| `key_id`, `workspace_id`, `model`, `provider` | Filters. |

The label, session, cache and BYOK reports take `from` and `to` too.

Response rows:

| Report | Each row has |
|---|---|
| `/v1/activity` | The group field (`date`, `model`, `provider`, `key_id`, `workspace_id`, `member` or `finance_category`), `requests`, `usage_usd`, `byok_usage_usd`, `input_tokens`, `cached_input_tokens`, `cache_write_tokens`, `output_tokens`, `reasoning_tokens`, `cache_hit_rate`. |
| `/v1/activity/labels` | `value`, `requests`, `attempts`, `usage_usd` and the token counts. |
| `/v1/activity/sessions` | `session_id`, `requests`, `usage_usd`, `models`, `first_seen`, `last_seen`. Query `limit`: at most 500. |
| `/v1/activity/cache` | `input_tokens`, `cached_input_tokens`, `cache_write_tokens`, `cache_hit_rate`, `estimated_savings_usd`. |
| `/v1/activity/byok` | `requests`, `list_cost_usd`, `negotiated_discount_percent`, `negotiated_cost_usd` (the list cost less the discount set with `PATCH /v1/connections/<id>`). |

- `cache_hit_rate` is cached input tokens divided by input tokens.
- `estimated_savings_usd` is the cached tokens times the difference between the input price and
  the cached input price.

`/v1/activity/cache/advice` returns these findings:

| Finding | When |
|---|---|
| `low_hit_rate` | A model with 1M+ input tokens and a hit rate under 20%, with the most it could save. |
| `cache_writes_unread` | Cache writes that no later request read. |
| `no_session_ids` | Over 30% of requests carry no session id. |

### Spend forecast

`GET /v1/activity/forecast` projects your org's spend on RouterPlus credits to the end of the
current UTC month, per group. It also gives the first day each budget or key limit is projected
to run out. BYOK spend is not projected.

```bash
curl "https://demo-api.routerplus.com/v1/activity/forecast?group_by=workspace&horizon=quarter" \
  -H "Authorization: Bearer $TM_MANAGEMENT_KEY"
```

| Parameter | Notes |
|---|---|
| `group_by` | `model` (default), `workspace`, `key` or `label`. |
| `label` | Required with `group_by=label`. A label key: 1 to 40 of `a-z 0-9 _ . -`. |
| `horizon` | `month` (default): to the end of the current UTC month. `quarter`: to the end of the third month after it. |
| `scenario` | Up to 10 changes. See [Scenarios](#scenarios). With `GET`, send it as URL-encoded JSON. |

`POST` takes the same four fields as a JSON body. An unknown field returns `400`.

How RouterPlus projects each group's daily spend:

| `method` | When | How |
|---|---|---|
| `weekday_trend_14d` | 14 or more days of history | A least-squares line through the last 14 days, times each weekday's factor from the last 28 days, clipped at zero. |
| `trailing_mean` | 3 to 13 days of history | The mean daily spend of those days. No weekday pattern, no trend. |
| `insufficient_history` | Fewer than 3 days | No projection. `projected_usd`, `low_usd` and `high_usd` are null. |

`low_usd` and `high_usd` are about a 90% range. History counts from the first day with spend.

Response:

| Field | Notes |
|---|---|
| `group_by`, `label`, `horizon` | The parameters used. |
| `as_of` | Today, UTC date. |
| `periods` | The months of the horizon: `{period, start, end}`. |
| `method` | `{trend_days: 14, pattern_days: 28, min_history_days: 3, band: "90%", descriptions}`. |
| `total` | The org's projection. See the projection fields below. |
| `data` | One row per group, largest first, at most 200. Each row has the group field (`model`, `workspace_id`, `key_id` or `value`), `name` for workspaces and keys, and the projection fields. |
| `crossings` | Budgets and key limits projected to run out before the end of the horizon, earliest first. |
| `scenario` | Null, or the scenario's result. |
| `daily` | `{start, usd}`: the org's actual spend per day, from 42 days before today. |

Projection fields: `spent_so_far_usd` (this month, today included), `projected_usd`, `low_usd`,
`high_usd`, `method`, `history_days`, `history_too_short`, and `periods` (per month:
`period`, `start`, `end`, `spent_usd`, `projected_usd`, `low_usd`, `high_usd`).

A crossing:

| Field | Notes |
|---|---|
| `kind` | `guardrail_budget` or `key_limit`. |
| Budget fields | `guardrail_id`, `budget_id`, `target_kind` (`group` for a shared budget), `target_id`, `model`, `provider`, `spent_known`. |
| Key limit fields | `key_id`, `name`, `key_prefix`, `limit_kind` (`limit` or `monthly_cap`). |
| `period`, `limit_usd`, `spent_usd` | The limit and its spend in the current period. |
| `crosses_on` | The first UTC date the projection reaches the limit. Spend resets at each period start. |
| `already_exceeded` | `true` when spend has already reached the limit. `crosses_on` is then today. |
| `method`, `history_days` | How that scope's spend was projected. |

#### Scenarios

A scenario changes the history that RouterPlus fits the projection to. It never changes spend so
far.

| Change | Does |
|---|---|
| `{"scale": n}` | Multiplies all traffic by `n` (0 to 100). |
| `{"scale": n, "workspace_id": "..."}` | Multiplies one workspace's traffic. Use `model` or `key_id` in place of `workspace_id` for one model or key. Name at most one. |
| `{"swap": {"from": "a", "to": "b"}}` | Moves model `a`'s traffic to model `b`. It prices the last 14 days of `a`'s tokens on `b`'s cheapest catalog route that your org guardrails allow. |

```bash
curl https://demo-api.routerplus.com/v1/activity/forecast \
  -H "Authorization: Bearer $TM_MANAGEMENT_KEY" \
  -H "content-type: application/json" \
  -d '{"horizon": "month", "scenario": [{"scale": 2}, {"swap": {"from": "google/gemini-3.1-pro", "to": "google/gemini-3.5-flash"}}]}'
```

`scenario` in the response has:

- `changes`: each change. A swap adds `provider`, `cost_ratio` and `tokens_basis_days` (14), and a
  `note` when `a` had no spend in the last 14 days.
- `total`: the projection fields and `delta_usd`, the change from the base projection.
- `data`: per group, `projected_usd`, `low_usd`, `high_usd`, `delta_usd` and `method`.
- `crossings`: the crossings under the scenario.

A swap returns `400` when the target model is not allowed by your org guardrails, has no catalog
price on an allowed route, or when one model is swapped twice.

### Savings

`GET /v1/activity/savings` reprices your traffic on RouterPlus credits for a window. For each
model and provider, it shows what the same tokens would cost on a cheaper route of the same model,
and on cheaper models of a similar quality tier. BYOK traffic is left out.

```bash
curl "https://demo-api.routerplus.com/v1/activity/savings?days=30" \
  -H "Authorization: Bearer $TM_MANAGEMENT_KEY"
```

| Query | Notes |
|---|---|
| `days` | An integer from 1 to 90. Default 30. The window ends today. |

The rules:

- A suggestion must save at least 1% of the baseline.
- A suggestion meets the guardrails of the keys that sent the traffic.
- A model of a similar tier has an Artificial Analysis intelligence index at most 3 points below
  the current model's. A model with no score is not compared. The answer counts those models.
- Each row lists at most 3 other models.
- The figures reprice token counts. They do not show that answers would be as good. Each row
  with other models includes the body of an eval to test them.

Response:

| Field | Notes |
|---|---|
| `days`, `from`, `to` | The window. |
| `scores_status` | Whether benchmark scores are loaded. `unavailable` when the server has no model catalog. |
| `tier_points` | 3. |
| `summary` | `spend_usd`, `route_saving_usd`, `model_saving_usd`. |
| `data` | One row per model and provider, largest saving first. |
| `notes` | Plain-text notes on what the figures mean. |
| `source`, `source_url` | `Artificial Analysis`, `https://artificialanalysis.ai/`. |

A `data` row:

| Field | Notes |
|---|---|
| `model`, `provider`, `requests`, `spend_usd` | The traffic. |
| `tokens` | `input`, `cached_input`, `cache_write`, `output`, `reasoning`. |
| `baseline_usd`, `baseline_basis` | The cost the savings compare against. `catalog_price` when the current route has a catalog price, else `spend` (what you were charged). |
| `intelligence_index` | The model's score, or null. |
| `cheapest_route` | Null, or `{provider, cost_usd, saving_usd, saving_percent, confidence, note}` for the same model on another provider. `confidence` is `high`, or `medium` against `spend`. |
| `route_note` | Why there is no `cheapest_route`, or null. |
| `alternative_models` | Up to 3 of `{model, provider, intelligence_index, index_difference, cost_usd, saving_usd, saving_percent, confidence, note}`. `confidence` is `medium` for an equal or higher score, `low` for a lower one. |
| `alternatives_note` | Why there are no alternatives, or how many models had no score. |
| `eval` | Null, or `{method: "POST", path: "/v1/evals", body, note}`: an eval of the current and other models, with a pairwise judge. Replace `cases` with 20 or more real prompts. |
| `best_saving_usd` | The larger of the route saving and the best model saving. |

### Labels

Label a request in one of three ways:

- The header `x-tm-labels: team=search,feature=autocomplete` (also `x-routerplus-labels`).
- The body field `trace`: an object of string values. RouterPlus removes it before the request
  reaches a provider.
- Labels set on the key (`PATCH /v1/keys/<id> {labels}`).

Rules:

- For one name, a request's own label wins over the key's.
- At most 20 labels. Names are lowercase letters, digits, `_`, `.` and `-`, up to 40 characters.
  Values are up to 200 characters.
- RouterPlus adds `task` (the kind of request).
- `x-tm-finance-category` sets the request's finance category.

## Security report

| Route | Does |
|---|---|
| `GET /v1/security/keys` | Lists every active API key with flags. `summary` counts each flag. |

| Flag | True when |
|---|---|
| `no_limit` | The key has no spend limit. |
| `no_expiry` | The key never expires. |
| `idle` | No request in 30 days, or no request ever and the key is older than 7 days. |
| `expiring_soon` | The key expires within 7 days. |
| `no_ip_allowlist` | The key works from any address. |
| `rotated` | A rotation replaced the key, and it still works in its grace period. |

Each key also has `last_used_at`, `created_at`, `expires_at` and `usage_30d_usd`.

## Audit log

| Route | Does |
|---|---|
| `GET /v1/audit` | Returns `{source, at, actor, action, subject}` rows, newest first. |

- `source` is `governance`, `identity`, `connection` or `routing_policy`.
- Query: `since`, `until`, `action`, `actor`, `limit` (at most 500, default 100).
- Grant changes write the actions `grant.request`, `grant.approve`, `grant.reject` and
  `grant.cancel`.

## SLA report

| Route | Does |
|---|---|
| `GET /v1/sla/report?month=YYYY-MM` | Your org's availability in one UTC month. |

Without `month`, it reports the current month up to now. A month in the future returns `400`.

The report counts requests. A request's result is its last attempt:

| Class | The last attempt |
|---|---|
| `good` | Completed. |
| `buyer_caused` | Was cancelled by the client, ended with HTTP 400, 413 or 429, or was refused while the org's configuration changed. |
| `upstream_quota` | Hit a provider pool's quota. |
| `failed` | Any other failure. |
| `pending` | Is not settled yet. Left out of the other counts. |

```json
{"data": {"month": "2026-09", "period_start": "2026-09-01T00:00:00.000Z", "period_end": "2026-10-01T00:00:00.000Z", "complete": true,
  "requests": {"total": 75, "good": 57, "buyer_caused": 4, "upstream_quota": 1, "failed": 13, "pending": 1},
  "availability_percent": 82.6667,
  "degraded": {"bucket_minutes": 5, "min_requests": 20, "failed_percent_over": 5, "minutes": 5, "period_minutes": 43200,
    "minutes_percent_available": 99.9884, "buckets": [{"start": "2026-09-10T12:05:00.000Z", "requests": 20, "failed": 2}]},
  "credit": null}}
```

| Field | Meaning |
|---|---|
| `availability_percent` | (`total` - `failed`) / `total` x 100. Null with no requests. |
| `degraded.minutes` | 5 minutes for each 5-minute bucket with at least 20 requests and more than 5% `failed`. |
| `credit` | Null unless your agreement sets credit tiers. Then it is `{tiers, tier}`, where `tier` is the tier for this month, or null. |

## Configuration backup

| Route | Does |
|---|---|
| `GET /v1/config/export` | Exports the configuration. Add `?include_secrets=true` to include secrets. |
| `POST /v1/config/import` | `{mode, dry_run?, config}`, where `config` is an export. |

The export:

```json
{"version": 1, "exported_at": "...", "guardrails": [...], "presets": [...], "workspaces": [...],
 "notification_channels": [...], "broadcast_destinations": [...]}
```

Secrets are written as `"__secret__"` unless you add `?include_secrets=true`.

Import modes:

| Value | Does |
|---|---|
| `mode: "merge"` | Creates what is missing and updates what matches. Workspaces and guardrails match by name, presets by slug, channels and destinations by id, then by address or name. |
| `mode: "replace"` | Does what `merge` does, then deletes the guardrails and presets that the file does not list. |
| `dry_run: true` | Returns the same counts and changes nothing. |

A `"__secret__"` value keeps the stored secret. RouterPlus skips a record that needs a secret you
did not send.

The response:

```json
{"data": {"mode": "merge", "dry_run": false,
  "counts": {"workspaces": {"created": 1, "updated": 0, "deleted": 0}, "guardrails": {...}, "assignments": {...},
             "presets": {...}, "notification_channels": {...}, "broadcast_destinations": {...}},
  "skipped": [{"kind": "notification_channels", "ref": "slack:...", "reason": "..."}]}}
```
