# Workspace API

An Owner or Admin of a team workspace can do with an API key what they do on the **Team** page and the **Plan** page: read the workspace, invite and remove people, change roles, set PAYG limits, buy and change seats, and move Personal balance into the team. The key needs workspace access, which you choose when you create it. See [Authentication and API keys](/en/docs/authentication#workspace-access). Deleting the workspace, transferring ownership, leaving it and accepting invites stay in the dashboard. These endpoints are specific to kenari and have no OpenAI or Anthropic equivalent.

Team workspaces are open to every account. If they are ever switched off, a key cannot be given workspace access, so its requests to this API answer `403` with `workspace_access_required`. A key that already has workspace access answers `403` with `teams_not_enabled` while team workspaces are closed.

Send the key in the `Authorization` header as `Bearer kn-...`, and send JSON bodies with `Content-Type: application/json`.

## Access levels

A key has one of four levels, set when it is created: none, **Read**, **Manage** and **Manage + payments**. Each level includes the ones before it. The table lists every route, the level it needs, and who must hold the key.

| Route | Level | Key held by |
| --- | --- | --- |
| `GET /v1/workspace` | **Read** | Any member |
| `PATCH /v1/workspace` | **Manage** | Owner or Admin |
| `GET /v1/workspace/members` | **Read** | Any member |
| `PATCH /v1/workspace/members/{user_id}` | **Manage** | Owner or Admin |
| `DELETE /v1/workspace/members/{user_id}` | **Manage** | Owner or Admin |
| `PUT /v1/workspace/members/{user_id}/cap` | **Manage** | Owner or Admin |
| `PUT /v1/workspace/member-cap-default` | **Manage** | Owner or Admin |
| `GET /v1/workspace/invites` | **Read** | Owner or Admin |
| `POST /v1/workspace/invites` | **Manage** | Owner or Admin |
| `DELETE /v1/workspace/invites/{id}` | **Manage** | Owner or Admin |
| `GET /v1/workspace/seats` | **Read** | Any member |
| `POST /v1/workspace/seats/quote` | **Read** | Owner or Admin |
| `POST /v1/workspace/seats` | **Manage + payments** | Owner or Admin |
| `PATCH /v1/workspace/seats/{id}` | **Manage + payments** | Owner or Admin |
| `DELETE /v1/workspace/seats/{id}` | **Manage** | Owner or Admin |
| `POST /v1/workspace/seats/{id}/cancel-change` | **Manage** | Owner or Admin |
| `POST /v1/workspace/seats/reactivate` | **Manage + payments** | Owner or Admin |
| `POST /v1/workspace/fund-from-personal` | **Manage + payments** | Owner or Admin |

A key acts as the person who created it, with that person's role at the moment of each request. An Owner or Admin always creates the key, but the role can change afterwards. If the person is demoted to Member, the routes marked Owner or Admin refuse the key with `403` and `permission_denied`, and the routes marked Any member keep working. If the person leaves or is removed from the workspace, the key is revoked, even when their other keys are handed to another member.

## Read the workspace

Every `_micro_idr` field in this API is in micro-Rupiah, which is Rupiah times 1,000,000. Rp 1 is `1000000`. Times are Unix seconds. See [How billing works](/en/docs/billing).

`GET /v1/workspace` returns the workspace the key belongs to. It needs **Read**. In a Personal workspace it returns only `id`, `kind` (`personal`) and `role`.

```json
{
  "id": "5b0e3a52-6f56-4c52-9d6f-0f3f3f4a9c11",
  "name": "Content team",
  "kind": "team",
  "role": "admin",
  "member_limit": 25,
  "seat_mode": true,
  "balance_micro_idr": 250000000000,
  "default_cap_micro_idr": 100000000000
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | The workspace id. |
| `name` | string | The team's name. |
| `kind` | string | `team`. |
| `role` | string | The role of the person who holds the key: `owner`, `admin` or `member`. |
| `member_limit` | integer | The most people the workspace can hold. Pending invites count toward it. |
| `seat_mode` | boolean | `true` when the workspace buys per-seat plans. |
| `balance_micro_idr` | integer | The workspace balance. |
| `default_cap_micro_idr` | integer or null | The PAYG limit a new member joins with. `null` when none is set, and always `null` for a Member. |

`GET /v1/workspace/members` returns `members` and `default_cap_micro_idr`, the same value as above. Each member has these fields:

| Field | Type | Description |
| --- | --- | --- |
| `user_id` | string | The person's id. The member routes below take it in the path. |
| `email` | string | The person's email address. |
| `role` | string | `owner`, `admin` or `member`. |
| `joined_at` | integer | When the person joined. |
| `last_seen_at` | integer or null | When the person was last active. |
| `seat` | object or null | The seat the person holds: `plan_id`, and `active`, which is `false` once the workspace's plan has lapsed. |
| `cap` | object or null | The person's PAYG limit: `cap_micro_idr`, `spent_micro_idr` in the current window and `window_end`. `null` when no limit is set. |
| `payg` | object or null | What the person drew from the workspace balance in the 30 day window, limit or not: `spent_micro_idr` and `window_end`. |

A key held by a Member reads `cap` and `payg` of its own row only. Every other row has `null` there.

```bash
curl https://kenari.id/v1/workspace/members \
  -H "Authorization: Bearer $KENARI_API_KEY"
```

```json
{
  "members": [
    {
      "user_id": "usr_8d1f6c0e-3a74-4b0a-8f0e-6f1b2c9d4e21",
      "email": "owner@example.com",
      "role": "owner",
      "joined_at": 1788300000,
      "last_seen_at": 1790890000,
      "seat": { "plan_id": "plan-studio-v3", "active": true },
      "cap": null,
      "payg": { "spent_micro_idr": 12000000000, "window_end": 1790892000 }
    }
  ],
  "default_cap_micro_idr": 100000000000
}
```

`GET /v1/workspace/invites` returns the pending invites, newest first: `id`, `email`, `role`, `expires_at` and `created_at`. A key held by an Owner or Admin reads it. The invite token is never returned, because it only travels in the invitee's email.

`GET /v1/workspace/seats` returns `seat_mode`, `cycle_end` and `seats`. A key held by an Owner or Admin also gets `plan_status`, `auto_renew`, `renewal_total_micro_idr` and every seat, with the holder's email. A key held by a Member gets its own seat only. Each seat has `id`, `plan_id`, `plan_name`, `user_id` (`null` when the seat is unassigned), `email` (the holder's email, `null` when the seat is unassigned), `next_plan_id` (the plan it moves to at renewal), `remove_at_renewal`, `weekly_reset_available` (whether the seat's weekly quota reset is still unused) and `windows`. `windows` holds the spend and budget of each quota window in micro-Rupiah, `cost_5h` and `limit_5h`, `cost_week` and `limit_week`, `cost_month` and `limit_month`, and the seconds until each window rolls, `resets_5h_secs`, `resets_week_secs` and `resets_month_secs`.

The `personal_*` fields show the Personal balance of the person who holds the key. They appear only for a **Manage + payments** key, and below that level they are left out. `GET /v1/workspace/seats` carries `personal_balance_micro_idr`.

## Manage members and invites

These routes need **Manage** and a key held by an Owner or Admin. Each accepts an `Idempotency-Key` header. See [Idempotency](#idempotency).

### Rename the workspace

`PATCH /v1/workspace` renames the team. The answer is `{"workspace": {...}}` with the new name.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Yes | The new name. Trimmed, 1 to 40 characters, with no control characters. |

### Change a role

`PATCH /v1/workspace/members/{user_id}` changes a member's role. The answer is `{"user_id": "...", "role": "..."}`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `role` | string | Yes | `admin` or `member`. |

The Owner's role never changes here. Ownership moves only in the dashboard.

### Remove a member

`DELETE /v1/workspace/members/{user_id}` removes a member and, in the same step, decides what happens to the keys the person holds in this workspace. The request takes a JSON body, and the body is required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `keys` | string | Yes | `revoke` or `transfer`. |
| `transfer_to` | string | With `transfer` | The `user_id` of another member who takes the keys. |

With `revoke`, every key the person holds here is revoked and stops working. With `transfer`, the keys keep working under `transfer_to`, and each key then acts as that member. A key with workspace access is revoked instead, because it acts as the person who created it. A shared API key handed to a Member is revoked too. The answer is `{"removed": true, "keys_revoked": 0, "keys_transferred": 3}`, with the real counts.

The Owner cannot be removed. A key cannot remove its own holder, because leaving a workspace is a dashboard action.

### Set a member's PAYG limit

`PUT /v1/workspace/members/{user_id}/cap` sets or removes the PAYG limit of one member. The limit is the most the person can draw from the workspace balance in a rolling 30 days: PAYG use beyond the seat's quota, web search, images, video and other fees. The answer is `{"user_id": "...", "cap": {...}}`, where `cap` has the same fields as in the member list, or `null` after a removal.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `cap_micro_idr` | integer or null | Yes | The limit in micro-Rupiah, a multiple of `1000000`, from `1000000` (Rp 1) to `1000000000000000` (Rp 1,000,000,000). `null` removes the limit. |

The field must be present. A body without it is refused, so an empty body never removes a limit by accident. A change never touches the spend already counted in the window. The Owner has no limit, and a person's own limit is set by someone else, so an Admin key cannot set or remove the limit of its own holder.

### Set the default limit for new members

`PUT /v1/workspace/member-cap-default` sets the PAYG limit that a person gets when they accept an invite from now on. Members already in the workspace keep the limit they have. The answer is `{"default_cap_micro_idr": ...}`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `cap_micro_idr` | integer or null | Yes | The same range as a member's own limit. `null` removes the default. |

### Invite someone

`POST /v1/workspace/invites` invites an email address. kenari emails the invite in the name of the person who holds the key, and it is valid for 7 days. The answer is `201`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | Yes | The address to invite, up to 254 characters. |
| `role` | string | Yes | `admin` or `member`. |

```bash
curl https://kenari.id/v1/workspace/invites \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "dewi@example.com",
    "role": "member"
  }'
```

```json
{
  "id": "0c9d7e1a-52b3-4f6e-9a7d-3e8b1f2a4c55",
  "email": "dewi@example.com",
  "role": "member",
  "expires_at": 1791497000,
  "email_sent": true
}
```

`email_sent` is `false` when the email could not be sent. The invite still exists, so revoke it and invite again. Inviting an address that already has a pending invite replaces that invite. An address that is already a member, or a workspace at its member limit, returns `409`.

A workspace can hold 50 pending invites and create 20 invites in any 24 hours, the same limits as in the dashboard. Over them, the request fails with `429` and `pending_limit_reached` or `daily_limit_reached`. The limits count for the workspace, so more keys do not raise them.

### Revoke an invite

`DELETE /v1/workspace/invites/{id}` revokes a pending invite, and its link stops working. The `id` is the one in the invite list. The answer is `{"revoked": true}`.

## Seats

A seat is a paid plan slot in a team. The level each route needs is in the table above. A `plan_id` is the `id` of a plan from `GET /api/plans`, which needs no key. See [Subscriptions](/en/docs/subscriptions).

Seats bought through the API are paid from the workspace balance. Any `pay_from` other than `"workspace"`, `"personal"` included, is refused with `400` and `pay_from_personal_not_supported`. To pay with your own money, move Personal balance into the workspace first. See [Move Personal balance into the team](#move-personal-balance-into-the-team).

A seat is charged at the price of the moment you buy it. If the price changes after a quote, the purchase charges the new price, so call quote right before you buy.

### Quote a purchase

`POST /v1/workspace/seats/quote` prices a purchase and changes nothing. It needs **Read** and a key held by an Owner or Admin.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | Yes | The plan to price. |
| `count` | integer | No | The number of seats to add, 1 to 25. The default is 1. It is ignored with `seat_id`. |
| `seat_id` | string | No | Price raising this seat to `plan_id` instead of adding seats. |

```json
{
  "charge_micro_idr": 199000000000,
  "saldo_micro_idr": 250000000000,
  "gap_micro_idr": 0
}
```

`charge_micro_idr` is what the purchase charges now. `saldo_micro_idr` is the workspace balance, and `gap_micro_idr` is how much it is short, `0` when it covers the charge. A **Manage + payments** key also gets `personal_saldo_micro_idr`, the Personal balance of the person who holds the key, and `personal_gap_micro_idr`, how much it is short.

### Buy seats

`POST /v1/workspace/seats` buys seats of one plan and charges the workspace balance. It needs **Manage + payments**, and `Idempotency-Key` is required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | Yes | The plan to buy. |
| `count` | integer | Yes | The number of seats, 1 to 25. |
| `user_id` | string | No | The member who gets the first seat. Leave it out to keep the seats unassigned. |
| `pay_from` | string | No | Only `workspace`, which is the default. |

```json
{
  "ok": true,
  "seat_ids": ["3f2c1a90-7d4e-4b8a-9c16-5e0a8b7d2f13"],
  "charged_micro_idr": 199000000000,
  "seat_mode": true
}
```

A workspace balance that is short returns `402` and `insufficient_balance`, with `error.gap_micro_idr`.

### Change a seat

`PATCH /v1/workspace/seats/{id}` assigns a seat to a member, unassigns it, raises its plan now, or lowers its plan at renewal. It needs **Manage + payments** for every body, because assigning and raising share the route and a raise charges. `Idempotency-Key` is required. The answer is `{"ok": true, "charged_micro_idr": 0}`, where the amount is `0` when nothing is charged.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `user_id` | string or null | No | Left out, the holder stays. `null` unassigns the seat. A member's `user_id` assigns it. A person can hold one seat. |
| `plan_id` | string | No | The plan to move the seat to. A higher plan applies now and charges the difference for the rest of the cycle. A lower plan applies at renewal. |
| `expect_unassigned` | boolean | No | The default is `false`. With `true`, the request is refused with `409` when someone holds the seat by now, instead of taking it from them. |
| `pay_from` | string | No | Only `workspace`, which is the default. |

The body must hold `user_id` or `plan_id`. A raise is paid from the workspace balance and returns `402` when it is short.

### Remove a seat

`DELETE /v1/workspace/seats/{id}` flags the seat for removal at the next renewal. It needs **Manage**. The answer is `{"ok": true}`. It returns `409` when the removal would leave the workspace with no seat.

### Cancel a pending change

`POST /v1/workspace/seats/{id}/cancel-change` clears a pending lower plan and a pending removal on the seat. It needs **Manage**. The answer is `{"ok": true}`.

### Reactivate seats

`POST /v1/workspace/seats/reactivate` buys the seats of a lapsed workspace again. It charges the renewal total to the workspace balance, applies the pending changes and starts a new cycle. There is no early renewal of an active cycle. It needs **Manage + payments**, and `Idempotency-Key` is required.

The body is ignored, except that a `pay_from` other than `workspace` is refused. The answer has `ok`, `charged_micro_idr` and `cycle_end`. A short balance returns `402`.

## Move Personal balance into the team

`POST /v1/workspace/fund-from-personal` moves money from the Personal balance of the person who holds the key into the workspace. It needs **Manage + payments**, and `Idempotency-Key` is required. The move is one way: money never leaves a team.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `amount_idr` | integer | Yes | The amount in whole Rupiah, from 1000 to 100000000. This field is in Rupiah, not micro-Rupiah. |

```json
{
  "ok": true,
  "amount_micro_idr": 500000000000,
  "balance_micro_idr": 750000000000,
  "personal_balance_micro_idr": 120000000000
}
```

`amount_micro_idr` is the amount moved. `balance_micro_idr` is the workspace balance after the move, and `personal_balance_micro_idr` is the Personal balance after it.

Each key has a daily limit for what it may move, counted over a rolling 24 hours. You set it when you create the key. See [Authentication and API keys](/en/docs/authentication#workspace-access). A transfer that would pass the limit is refused with `403` and `fund_cap_reached`, and the message says how much is left. A Personal balance that is short returns `402`, with `error.gap_micro_idr` and `error.source` set to `personal`. A transfer that ends in a `500`, or that is cut off before it answers, may have moved the money, so its amount counts against the limit for the next 24 hours. Read the balances with the GET routes before you try again.

kenari sends an email to the person who holds the key after every transfer, with the amount, the workspace and the key.

## Idempotency

Send an `Idempotency-Key` header with 1 to 128 visible ASCII characters, which means no spaces. It is required on four routes: `POST /v1/workspace/seats`, `PATCH /v1/workspace/seats/{id}`, `POST /v1/workspace/seats/reactivate` and `POST /v1/workspace/fund-from-personal`. Without it they answer `400` and `idempotency_key_required`. Every other change accepts the header too.

- The same `Idempotency-Key` with the same body, within 90 days, returns the stored answer with the header `Idempotent-Replayed: true`, and nothing runs again.
- The same `Idempotency-Key` with a different body or route returns `409` and `idempotency_key_reused`.
- The same `Idempotency-Key` while the first request is still running returns `409` and `request_in_progress`.

```bash
curl https://kenari.id/v1/workspace/seats \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "plan_id": "plan-studio-v3",
    "count": 1
  }'
```

Use a new `Idempotency-Key` for each purchase or transfer. Keep the same one only when you retry the same request, for example after a timeout, so the retry cannot charge twice. Running the command above again creates a new value, so store it before you send the request if you may need to retry.

The stored answer can be an error. A retry of a refused purchase returns the same refusal, so after you fix the cause, such as a short balance, send the next try with a new `Idempotency-Key`. A request that was cut off in the middle can stay in progress. Read the state with the GET routes, and if the change did not happen, send it again with a new `Idempotency-Key`.

## Errors

Errors use the `/v1` error envelope, and you branch on `error.code`. A `401` is the exception: a missing, invalid, expired or revoked key gets a short plain-text body, not JSON.

```json
{
  "error": {
    "message": "This key does not have the workspace access this route needs.",
    "type": "permission_error",
    "param": null,
    "code": "workspace_access_required"
  }
}
```

`type` follows the status: `invalid_request_error` for 400, `insufficient_balance` for 402, `permission_error` for 403, `not_found_error` for 404, `conflict_error` for 409, `rate_limit_error` for 429 and `server_error` for 500. A 500 always carries the message `internal error`.

Messages are in English. Send `x-kenari-locale: id`, or an `Accept-Language` header whose first language is `id`, to get Indonesian for the errors that come from the same rules as the dashboard. The errors that this API raises itself, such as the access, idempotency and limit errors below, are always in English.

| Status | Code | When |
| --- | --- | --- |
| 400 | `invalid_request` | The body is not valid JSON for the route, a value is refused, an id in the path is not 1 to 128 characters of letters, digits, hyphen or underscore, or the `Idempotency-Key` header is not valid. |
| 400 | `idempotency_key_required` | The route needs an `Idempotency-Key` header and the request has none. |
| 400 | `pay_from_personal_not_supported` | A seat request has a `pay_from` other than `workspace`. |
| 401 | None | The key is missing, invalid, expired or revoked. The body is plain text. |
| 402 | `insufficient_balance` | The balance that pays is short. `error.gap_micro_idr` says by how much, and `error.source` is `personal` when the short balance is the Personal balance. |
| 403 | `workspace_access_required` | The key's level is lower than the route needs, or the key is one that `kenari login` saved or a per-tool key. |
| 403 | `shared_key_not_allowed` | The key is a shared API key. |
| 403 | `restricted_key_not_allowed` | The key has a model restriction, a capability restriction, a spend cap or a token cap. |
| 403 | `teams_not_enabled` | Team workspaces are not available for this account. |
| 403 | `not_a_team_workspace` | The key's workspace is not a team workspace. |
| 403 | `permission_denied` | The route is for an Owner or Admin and the key is held by a Member, or the workspace rules refuse the change, such as removing the Owner. |
| 403 | `fund_cap_reached` | The transfer would pass the key's daily limit. The message says how much is left. |
| 404 | `not_found` | The member, invite or seat is not in the workspace. |
| 409 | `conflict` | The workspace is in a state that refuses the change, such as inviting a person who is already a member, a workspace at its member limit, or removing the last seat. |
| 409 | `idempotency_key_reused` | The `Idempotency-Key` was already used for a different body or route. |
| 409 | `request_in_progress` | The first request with this `Idempotency-Key` is still running. |
| 429 | `rate_limited` | More changes than the limits allow. A `Retry-After` header says how long to wait. See [Rate limits](/en/docs/rate-limits#workspace-api). |
| 429 | `pending_limit_reached` | The workspace has too many pending invites. Revoke some, then invite again. |
| 429 | `daily_limit_reached` | The workspace has reached its daily invite limit. |
| 500 | `internal_error` | Something failed in kenari. The change may or may not have happened, so read the state with the GET routes before you try again with a new `Idempotency-Key`. |

See [Errors](/en/docs/errors) for the rest.

## Usage by member

`GET /v1/usage`, `GET /v1/usage/daily` and `GET /v1/usage/log` take a `user_id` parameter that narrows the answer to one member. See [Usage](/en/docs/usage#who-can-read-what).

## Examples

This flow reads the members and invites one person if they are not in the workspace yet.

### curl

```bash
curl https://kenari.id/v1/workspace/members \
  -H "Authorization: Bearer $KENARI_API_KEY"

curl https://kenari.id/v1/workspace/invites \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "dewi@example.com",
    "role": "member"
  }'
```

### Python

```python
import os

import requests

base = "https://kenari.id/v1/workspace"
headers = {"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"}
email = "dewi@example.com"

members = requests.get(f"{base}/members", headers=headers)
members.raise_for_status()

if any(member["email"].lower() == email for member in members.json()["members"]):
    print("Already a member")
else:
    invite = requests.post(
        f"{base}/invites",
        headers=headers,
        json={"email": email, "role": "member"},
    )
    invite.raise_for_status()
    print("Invited, valid until", invite.json()["expires_at"])
```

### JavaScript

```javascript
const base = "https://kenari.id/v1/workspace";
const headers = { Authorization: `Bearer ${process.env.KENARI_API_KEY}` };
const email = "dewi@example.com";

const membersResponse = await fetch(`${base}/members`, { headers });
if (!membersResponse.ok) throw new Error(await membersResponse.text());
const { members } = await membersResponse.json();

if (members.some((member) => member.email.toLowerCase() === email)) {
  console.log("Already a member");
} else {
  const inviteResponse = await fetch(`${base}/invites`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({ email, role: "member" }),
  });
  if (!inviteResponse.ok) throw new Error(await inviteResponse.text());
  const invite = await inviteResponse.json();
  console.log("Invited, valid until", invite.expires_at);
}
```

## See also

- [Authentication and API keys](/en/docs/authentication)
- [Usage](/en/docs/usage)
- [MCP server](/en/docs/mcp)
- [Rate limits](/en/docs/rate-limits)
