Skip to content
kenari.

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.

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.

RouteLevelKey held by
GET /v1/workspaceReadAny member
PATCH /v1/workspaceManageOwner or Admin
GET /v1/workspace/membersReadAny member
PATCH /v1/workspace/members/{user_id}ManageOwner or Admin
DELETE /v1/workspace/members/{user_id}ManageOwner or Admin
PUT /v1/workspace/members/{user_id}/capManageOwner or Admin
PUT /v1/workspace/member-cap-defaultManageOwner or Admin
GET /v1/workspace/invitesReadOwner or Admin
POST /v1/workspace/invitesManageOwner or Admin
DELETE /v1/workspace/invites/{id}ManageOwner or Admin
GET /v1/workspace/seatsReadAny member
POST /v1/workspace/seats/quoteReadOwner or Admin
POST /v1/workspace/seatsManage + paymentsOwner or Admin
PATCH /v1/workspace/seats/{id}Manage + paymentsOwner or Admin
DELETE /v1/workspace/seats/{id}ManageOwner or Admin
POST /v1/workspace/seats/{id}/cancel-changeManageOwner or Admin
POST /v1/workspace/seats/reactivateManage + paymentsOwner or Admin
POST /v1/workspace/fund-from-personalManage + paymentsOwner 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.

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
}
FieldTypeDescription
idstringThe workspace id.
namestringThe team’s name.
kindstringteam.
rolestringThe role of the person who holds the key: owner, admin or member.
member_limitintegerThe most people the workspace can hold. Pending invites count toward it.
seat_modebooleantrue when the workspace buys per-seat plans.
balance_micro_idrintegerThe workspace balance.
default_cap_micro_idrinteger or nullThe 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:

FieldTypeDescription
user_idstringThe person’s id. The member routes below take it in the path.
emailstringThe person’s email address.
rolestringowner, admin or member.
joined_atintegerWhen the person joined.
last_seen_atinteger or nullWhen the person was last active.
seatobject or nullThe seat the person holds: plan_id, and active, which is false once the workspace’s plan has lapsed.
capobject or nullThe person’s PAYG limit: cap_micro_idr, spent_micro_idr in the current window and window_end. null when no limit is set.
paygobject or nullWhat 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.

Terminal window
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.

These routes need Manage and a key held by an Owner or Admin. Each accepts an Idempotency-Key header. See Idempotency.

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

FieldTypeRequiredDescription
namestringYesThe new name. Trimmed, 1 to 40 characters, with no control characters.

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

FieldTypeRequiredDescription
rolestringYesadmin or member.

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

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.

FieldTypeRequiredDescription
keysstringYesrevoke or transfer.
transfer_tostringWith transferThe 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.

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.

FieldTypeRequiredDescription
cap_micro_idrinteger or nullYesThe 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.

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": ...}.

FieldTypeRequiredDescription
cap_micro_idrinteger or nullYesThe same range as a member’s own limit. null removes the default.

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.

FieldTypeRequiredDescription
emailstringYesThe address to invite, up to 254 characters.
rolestringYesadmin or member.
Terminal window
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.

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.

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

FieldTypeRequiredDescription
plan_idstringYesThe plan to price.
countintegerNoThe number of seats to add, 1 to 25. The default is 1. It is ignored with seat_id.
seat_idstringNoPrice 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.

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

FieldTypeRequiredDescription
plan_idstringYesThe plan to buy.
countintegerYesThe number of seats, 1 to 25.
user_idstringNoThe member who gets the first seat. Leave it out to keep the seats unassigned.
pay_fromstringNoOnly 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.

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.

FieldTypeRequiredDescription
user_idstring or nullNoLeft out, the holder stays. null unassigns the seat. A member’s user_id assigns it. A person can hold one seat.
plan_idstringNoThe 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_unassignedbooleanNoThe 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_fromstringNoOnly 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.

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.

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}.

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.

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.

FieldTypeRequiredDescription
amount_idrintegerYesThe 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.

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.
Terminal window
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 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.

StatusCodeWhen
400invalid_requestThe 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.
400idempotency_key_requiredThe route needs an Idempotency-Key header and the request has none.
400pay_from_personal_not_supportedA seat request has a pay_from other than workspace.
401NoneThe key is missing, invalid, expired or revoked. The body is plain text.
402insufficient_balanceThe 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.
403workspace_access_requiredThe key’s level is lower than the route needs, or the key is one that kenari login saved or a per-tool key.
403shared_key_not_allowedThe key is a shared API key.
403restricted_key_not_allowedThe key has a model restriction, a capability restriction, a spend cap or a token cap.
403teams_not_enabledTeam workspaces are not available for this account.
403not_a_team_workspaceThe key’s workspace is not a team workspace.
403permission_deniedThe 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.
403fund_cap_reachedThe transfer would pass the key’s daily limit. The message says how much is left.
404not_foundThe member, invite or seat is not in the workspace.
409conflictThe 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.
409idempotency_key_reusedThe Idempotency-Key was already used for a different body or route.
409request_in_progressThe first request with this Idempotency-Key is still running.
429rate_limitedMore changes than the limits allow. A Retry-After header says how long to wait. See Rate limits.
429pending_limit_reachedThe workspace has too many pending invites. Revoke some, then invite again.
429daily_limit_reachedThe workspace has reached its daily invite limit.
500internal_errorSomething 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.

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.

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

Terminal window
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"
}'
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"])
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);
}