Teams, keys and tokens API
Lists the API routes for teams, members, invitations, SSH keys, signing keys and tokens.
Last updated
On this page
- Teams
- The team document
- POST /v1/teams
- GET /v1/teams
- GET /v1/teams/{slug}
- PATCH /v1/teams/{slug}
- GET /v1/teams/{slug}/profile
- DELETE /v1/teams/{slug}
- PATCH /v1/teams/{slug}/members/{email}
- DELETE /v1/teams/{slug}/members/{email}
- POST /v1/teams/{slug}/members/{email}/pause
- POST /v1/teams/{slug}/members/{email}/unpause
- POST /v1/teams/{slug}/invites
- DELETE /v1/teams/{slug}/invites/{id}
- GET /v1/invites/{token}
- POST /v1/invites/{token}/accept
- GET /v1/teams/{slug}/removals
- POST /v1/teams/{slug}/members/{email}/delete-now
- Keys
- POST /v1/keys
- GET /v1/keys
- DELETE /v1/keys/{*id}
- Tokens
- POST /v1/tokens
- GET /v1/tokens
- DELETE /v1/tokens/{id}
- CLI login
- POST /v1/cli/code
- GET /v1/cli/code/{code}
- POST /v1/cli/approve
- GET /v1/cli/token
- GET /v1/cli/tokens
- DELETE /v1/cli/tokens/{id}
Teams
The slug of a team is its handle. It has 3 to 39 lowercase letters, digits and -, and does not start or end with -. It is not reserved and never equals a person's handle. The roles member, admin and owner and their rights are in Roles. The last owner cannot step down or be removed.
A non-member gets 404 no such team on every route. A paused member gets 403 your access to this team is paused. A member below the role a route needs gets 403 your role does not allow that.
The team document
GET /v1/teams/{slug} returns this. A field marked "admins" is empty for a member.
{ "slug": "acme", "name": "Acme", "description": "The platform team", "createdAt": "2026-09-01T08:00:00Z", "public": true, "tagline": "We build the build farm", "location": "Pune", "website": "https://acme.example", "email": "[email protected]", "yourRole": "owner", "members": [ { "email": "[email protected]", "name": "Karthik", "username": "karthik", "role": "owner", "joinedAt": "2026-09-01T08:00:00Z", "state": "active" } ], "invites": [ { "id": "5d41402abc4b2a76b9719d911017c592a1b2c3d4e5f60718293a4b5c6d7e8f90", "email": "[email protected]", "role": "member", "invitedBy": "[email protected]", "expiresAt": "2026-10-25T08:00:00Z" } ]}| Field | Type | Meaning |
|---|---|---|
slug, name, description | string | the handle, the display name and a free description |
createdAt | string | RFC 3339 |
public, tagline, location, website, email | the public profile; see GET /v1/teams/{slug}/profile | |
yourRole | string | your role, so a client can decide what to offer. The server refuses anything the role does not allow regardless |
members | object[] | email, name, username (omitted for someone who never picked a handle), role, joinedAt (RFC 3339), state (active or paused) |
invites | object[] | open invitations: id, email, role, invitedBy, expiresAt. Empty unless you are an admin or owner |
POST /v1/teams
Makes a team and makes you its owner. Takes a session.
| Field | Type | Required | Rule |
|---|---|---|---|
slug | string | yes | The handle rule above. Trimmed |
name | string | yes | Not empty |
region | string | yes | An active region from GET /v1/regions. A team is placed in one region when it is made; no later call changes it except a region request |
{ "slug": "acme", "name": "Acme", "region": "centralindia" }Response: 201, the team as stored.
{ "_id": "acme", "name": "Acme", "description": "", "public": false, "tagline": "", "location": "", "website": "", "email": "", "region": "centralindia", "createdBy": "[email protected]", "createdAt": { "$date": { "$numberLong": "1760400000000" } }, "members": [ { "user": "[email protected]", "role": "owner", "joinedAt": { "$date": { "$numberLong": "1760400000000" } }, "state": "active" } ]}Errors:
400— the handle is refused, with the rule:handle must be at least 3 characters,handle must be 39 characters or fewer,handle must be lowercase,handle may use letters, digits and dashes only,handle may not start or end with a dash,that handle is reserved,that handle cannot be used; orteam name required.409—handle already taken, by a team or a person.422—region required;unknown region: {region}.502—could not read regions, or the API cannot write the team.503—regions unavailable.
GET /v1/teams
Lists the teams you belong to, including the ones where you are paused. Takes a session or a session tool token.
Response: 200, an array of teams as stored (the shape in POST /v1/teams), each with state, your own state in it.
[ { "_id": "acme", "name": "Acme", "region": "centralindia", "createdBy": "[email protected]", "createdAt": { "$date": { "$numberLong": "1760400000000" } }, "members": [{ "user": "[email protected]", "role": "owner", "joinedAt": { "$date": { "$numberLong": "1760400000000" } }, "state": "active" }], "public": false, "description": "", "tagline": "", "location": "", "website": "", "email": "", "state": "active" }]GET /v1/teams/{slug}
Reads a team. Any member can; takes a session or a session tool token.
Response: 200, the team document.
Errors:
403—your access to this team is paused.404—no such team.
PATCH /v1/teams/{slug}
Changes the name and description, and optionally the public profile. Any member can change the name and description; the profile takes an admin or owner. Takes a session.
| Field | Type | Required | Rule |
|---|---|---|---|
name | string | yes | Not empty |
description | string | no | Empty clears it |
profile | object | no | Admins and owners only. The whole profile, not a patch: a field you leave out is cleared |
profile fields, all optional:
| Field | Type | Rule |
|---|---|---|
public | boolean | true lets strangers read the profile. Default false |
tagline, location, email | string | |
website | string | Empty, or an http:// or https:// URL of at most 2048 characters. Any other scheme is refused |
{ "name": "Acme", "description": "The platform team", "profile": { "public": true, "tagline": "We build the build farm", "website": "https://acme.example" } }Response: 204. A refused profile changes nothing, not even the name.
Errors:
400— awebsitethat is nothttp://orhttps://(website must start with http:// or https://) or is too long;team name required.403—owner or admin only, for amemberwho sentprofile;your access to this team is paused.404—no such team.
GET /v1/teams/{slug}/profile
The team's public face. Anonymous: it needs no token. A team that has not set public is 404 no such team, the same as a team that does not exist.
Response: 200.
{ "slug": "acme", "name": "Acme", "description": "The platform team", "tagline": "We build the build farm", "location": "Pune", "website": "https://acme.example", "email": "[email protected]", "memberCount": 4}DELETE /v1/teams/{slug}
Deletes a team. Owners only. Takes a session. Takes no body.
Response: 204.
Errors:
403—your role does not allow that.404—no such team.
PATCH /v1/teams/{slug}/members/{email}
Changes a member's role. {email} is the member's email. An admin can make anyone a member or admin, and cannot change an owner. Only an owner can make anyone an owner or change an owner's role. Takes a session.
| Field | Type | Required | Rule |
|---|---|---|---|
role | string | yes | member, admin or owner |
{ "role": "admin" }Response: 204.
Errors:
403—your role does not allow that change;your role does not allow that.404—not a member;no such team.409—a team must keep at least one owner.
DELETE /v1/teams/{slug}/members/{email}
Removes a member, or lets a member leave. Takes no body and a session.
Anyone can remove themselves. Removing someone else takes admin for a member or admin, and owner for an owner. The removed person's keys stop being admitted to the team at once. Their team workspaces and sessions are marked for deletion by the next key sync, with a 7-day grace period; see GET /v1/teams/{slug}/removals.
Response: 204.
Errors:
403—your role does not allow that.404—not a member;no such team.409—transfer ownership before leaving, for the last owner leaving;a team must keep at least one owner, for removing the last owner.
POST /v1/teams/{slug}/members/{email}/pause
Pauses a member. A paused member keeps their row and role but cannot act under the team. /v1, git and the registry refuse them. Their sessions stop, and their team workspaces stop.
Nothing is deleted. Pause is lifted with unpause. Takes no body and a session.
You need the reach to pause that member. An admin can pause members and admins. An owner can pause anyone. A superadmin can pause any member of any team. Nobody can pause or unpause themselves. Git and registry access follows within a minute.
Response: 204, also when the member was already paused.
Errors:
403—you cannot pause yourself;your role does not allow that.404—no such team;not a member.409—a team must keep at least one active owner.
POST /v1/teams/{slug}/members/{email}/unpause
Lifts a pause. The same reach, the same rules and the same response as pause; the self refusal reads you cannot unpause yourself. It does not start anything that was stopped. Takes no body and a session.
POST /v1/teams/{slug}/invites
Invites someone by email. Admins and owners; only an owner can invite an owner. Takes a session.
| Field | Type | Required | Rule |
|---|---|---|---|
email | string | yes | Must contain @. Lower-cased |
role | string | no | member (the default), admin or owner |
{ "email": "[email protected]", "role": "member" }Response: 201. The raw token is shown once; the platform keeps only its hash, and id is that hash. Put the token in the link you send. An invitation lasts 7 days.
{ "id": "5d41402abc4b2a76b9719d911017c592a1b2c3d4e5f60718293a4b5c6d7e8f90", "token": "9f2c…64 hex characters…e1", "email": "[email protected]", "role": "member", "team_name": "Acme" }Errors:
400—a valid email is required.403—your role does not allow that;only an owner can invite an owner.404—no such team.409—already a member.
DELETE /v1/teams/{slug}/invites/{id}
Revokes an open invitation. {id} is the id from the team document or from the invite response. Admins and owners. Takes a session and no body.
Response: 204.
Errors:
403—your role does not allow that.404—no such invitation;no such team.
GET /v1/invites/{token}
What an invitation is for, so a page can show it before the person agrees. Takes a session. {token} is the raw token. A token that is spent, expired, revoked or invented is 404, all alike.
Response: 200.
{ "team": "acme", "teamName": "Acme", "email": "[email protected]", "role": "member", "invitedBy": "[email protected]" }Errors:
401— no session (auth required).404—no such invitation.
POST /v1/invites/{token}/accept
Joins the team the invitation names. Takes a session, and the session must be for the address the invitation was sent to. Takes no body.
Response: 200.
{ "team": "acme" }Errors:
403—this invitation was sent to a different email address.404—no such invitation.409—sign in first, when the address has never signed in.
GET /v1/teams/{slug}/removals
Who was removed from the team and is waiting to be cleaned up. Their team workspaces and sessions are marked when they leave and deleted after a 7-day grace period; re-adding the person clears the marks. You must be an admin or owner of the team, or a superadmin. This route is served by the workspace tier, so it takes a session or a CLI login and its 401 is missing or invalid token.
Response: 200, an array of the people waiting. delete_at is when their things become eligible for deletion, RFC 3339.
[{ "owner": "asha", "delete_at": "2026-10-25T08:00:00Z" }]owner is the person's handle. Deletion also needs the region to have member-removal deletion switched on; otherwise the marks stand and nothing is deleted.
Errors:
403—only a team admin can do this.404— you are not in the team (empty body).
POST /v1/teams/{slug}/members/{email}/delete-now
Ends the grace period for one removed person: their team workspaces and sessions become eligible for deletion at once. Where member-removal deletion is on, the platform deletes them within a minute. It never deletes a snapshot, a volume, a repository, an image or a team environment. You must be an admin or owner of the team, or a superadmin. Like the removals list it is served by the workspace tier: a session or a CLI login, and 401 missing or invalid token.
{email} is the person's handle, not an email, here; an email in that segment is refused with 400. You must confirm by naming both:
| Field | Type | Required | Rule |
|---|---|---|---|
person | string | yes | The handle in the path, any case |
team | string | yes | The slug in the path, any case |
{ "person": "asha", "team": "acme" }Response: 202. deletes_enabled says whether the region will act on the mark now.
{ "deletes_enabled": true }Errors:
400—confirm by naming the person and the team exactly;name the person by their handle; {handle} is not one.403—only a team admin can do this.404— you are not in the team (empty body).409—still a member of the team;not pending removal.503—team membership could not be checked.
Keys
A key belongs to a person, never to a team or a workspace. Registering one admits it to every team where you are an active member. Removing a member stops admitting the keys of that member.
Keys come in two kinds. An SSH key is for git over SSH and for connecting to your workspaces. A signing key (SSH or GPG) proves that you wrote a commit and never opens a connection. You can add the same key as both kinds; they are two entries.
All the key routes take a session or a CLI login.
POST /v1/keys
Adds a key.
| Field | Type | Required | Rule |
|---|---|---|---|
key | string | yes | An OpenSSH public key line, or an armoured OpenPGP public key block |
name | string | no | Default: the key's comment, else ssh key; for a GPG key the first verified email on it, else GPG key |
signing | boolean | no | true registers it for signing only. A GPG key must be true |
A body that carries a non-empty owner is 400: a key is yours, so there is no owner to name.
{ "name": "laptop", "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… karthik@laptop", "signing": false }Response: 201, the entry. _id is the key's SHA-256 fingerprint, prefixed sign: for a signing entry. owner is your email. fingerprints lists the fingerprints the entry answers to, lower-cased; a GPG key lists its primary key and every subkey. material is the key you sent, normalised to three fields for SSH.
{ "_id": "SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8", "kind": "sshkey", "owner": "[email protected]", "createdBy": "[email protected]", "name": "laptop", "material": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… karthik@laptop", "fingerprints": ["sha256:nthbg6kxupjwgl7e1igocsprotmxcdarlvikw6e5sy8"], "createdAt": { "$date": { "$numberLong": "1760400000000" } }}Errors:
400—keys belong to you, not to a namespace;a GPG key can only be added as a signing key;that is not an OpenPGP public key; or the SSH key line cannot be parsed.409—that key is already added.
GET /v1/keys
Lists your keys. ?kind=signing lists signing keys; anything else, or no query, lists SSH keys.
Response: 200, an array of the entries shown above. An entry added before keys were kept has no material; it still clones over SSH but cannot reach a workspace, so add it again.
DELETE /v1/keys/{*id}
Revokes a key. {*id} is the entry's _id. A fingerprint contains /, so send it as it is or percent-encoded; both reach the same route. Only you can revoke your own key; a teammate's is 404. Takes no body.
Response: 204.
Errors:
404—no such credential.
Tokens
A token authenticates git over HTTPS and the registry for one owner. You choose the owner when you make the token: your own handle, or a team you belong to. A token for acme pushes and pulls acme's repositories and images and nothing else. A leaked personal token therefore cannot reach a team. A token does not authenticate this API, and it has no lifetime: it works until you revoke it.
All the token routes take a session or a CLI login.
POST /v1/tokens
Makes a token.
| Field | Type | Required | Rule |
|---|---|---|---|
owner | string | yes | Your handle, or a team you are an active member of. Without it the answer is 404 no such owner |
name | string | yes | What it is for. 1 to 60 characters, trimmed |
{ "owner": "acme", "name": "ci" }Response: 201. The token is readable only here; only its digest is kept.
{ "token": "3f9a1c0e5b7d2a4c6e8f0a1b2c3d4e5f", "_id": "9b74c9897bac770ffc029102a200c5de0a6e9d9a2e0d8c1d5a2f3b4c5d6e7f80", "kind": "token", "owner": "acme", "createdBy": "[email protected]", "name": "ci", "createdAt": { "$date": { "$numberLong": "1760400000000" } }}Errors:
400—give the token a name;that name is too long.404—no such owner.502—could not create the token.
GET /v1/tokens
Lists the tokens of one owner. The query ?owner= is required: your handle, or a team you belong to. Any active member of a team sees all the team's tokens, whoever made them.
Response: 200, an array of entries as above without token.
Errors:
400—owner is required.404—no such owner.
DELETE /v1/tokens/{id}
Revokes a token at once. {id} is the _id. You can revoke a token of any owner that you can list. Takes no body.
Response: 204.
Errors:
404—no such credential, for an unknown id or one of another owner's.
CLI login
kloudlite login signs a computer in without a password. It asks for a code, you approve the code in the console while signed in, and the CLI collects a token. The token is a CLI login. It lasts 30 days and carries your username. You can revoke it at once with DELETE /v1/cli/tokens/{id}.
The three code routes share one limit per client address, by default 20 requests in 10 minutes, and answer 429 with Retry-After past it.
POST /v1/cli/code
Starts a login. Anonymous; it grants nothing until a signed-in person approves the code.
| Field | Type | Required | Rule |
|---|---|---|---|
device | string | no | What to call this computer. The approver sees it. At most 60 characters; default a computer |
Response: 201. expiresIn is in seconds: the code is worth typing for 10 minutes. code has no vowels and no 0, O, 1 or I.
{ "code": "BCDF-GH23", "poll": "5f2d8a0b9c3e4d1f7a6b5c4d3e2f1a0b", "expiresIn": 600 }Keep poll private; it is what collects the token.
Errors:
429— too many codes from this address.
GET /v1/cli/code/{code}
Which computer is asking, so the approval page can show it before it offers a button. Takes a session only; a CLI login cannot approve another. The code is case-insensitive.
Response: 200.
{ "device": "karthik's laptop", "expiresAt": "2026-10-18T06:22:00Z" }Errors:
404—no such code, for an unknown, expired or already approved code alike.429— as above.
POST /v1/cli/approve
Approves a code as you. Takes a session only, and you must have chosen a username.
| Field | Type | Required | Rule |
|---|---|---|---|
code | string | yes | The code, in any case |
{ "code": "bcdf-gh23" }Response: 204.
Errors:
400—pick a handle before signing in from the CLI.404—no such code.409—that code was already used.429— as above.
GET /v1/cli/token
The CLI polls this with ?poll= from POST /v1/cli/code. It is anonymous. The token is handed over once: a second poll is 410.
Response: 202 with an empty body while nobody has approved; then 200 once.
{ "token": "eyJhbGciOiJIUzI1NiJ9…", "expiresAt": "2026-11-17T06:12:00Z" }Errors:
410—that login expired: unknown, expired, nopoll, or already collected.
GET /v1/cli/tokens
Lists CLI logins. Takes a session or a CLI login. The query ?owner= defaults to your own handle.
Response: 200.
[{ "id": "8c1f0e7a6b5d4c3b2a190807f6e5d4c3", "name": "karthik's laptop", "createdAt": "2026-10-18T06:12:00Z", "expiresAt": "2026-11-17T06:12:00Z" }]expiresAt is createdAt plus 30 days. Past that the login stops working on its own.
Errors:
400—owner is required, when your token carries no username.404—no such owner.
DELETE /v1/cli/tokens/{id}
Signs a computer out. The CLI login stops working at once, on every route. {id} is the id from the list. Takes a session or a CLI login and no body.
Response: 204.
Errors:
404—no such credential.