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. 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
Section titled “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
Section titled “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.
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.
{ "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.
curl https://kenari.id/v1/workspace/members \ -H "Authorization: Bearer $KENARI_API_KEY"{ "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
Section titled “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.
Rename the workspace
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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. |
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" }'{ "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
Section titled “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}.
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.
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.
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
Section titled “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. |
{ "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
Section titled “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. |
{ "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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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. |
{ "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. 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
Section titled “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-Keywith the same body, within 90 days, returns the stored answer with the headerIdempotent-Replayed: true, and nothing runs again. - The same
Idempotency-Keywith a different body or route returns409andidempotency_key_reused. - The same
Idempotency-Keywhile the first request is still running returns409andrequest_in_progress.
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
Section titled “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.
{ "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. |
| 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 for the rest.
Usage by member
Section titled “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.
Examples
Section titled “Examples”This flow reads the members and invites one person if they are not in the workspace yet.
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
Section titled “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
Section titled “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);}