# API

The control plane is REST with JSON at `https://raw.computer/v1`. Its single source is the OpenAPI 3.1 document at
[`/v1/openapi.json`](https://raw.computer/v1/openapi.json): these docs, the SDKs and the CLI are made from it. Where
this page and the document disagree, the document is right.

## Authentication

Send a workspace API key in the `Authorization` header:

```http
Authorization: Bearer rc_live_…
```

A key is made in the console (**API keys**) or with `POST /v1/api_keys`, belongs to one person and acts in one
workspace, as `developer` (reads and writes) or `viewer` (reads). A key is never an admin and never acts at org level,
and it never does more than its person may do in its workspace. What only a workspace's admins do (its people,
invitations, name and residency) takes a [personal token](#personal-tokens) of an admin. A missing, unknown or revoked key gets
`401 unauthorized`; a key without the relation an action needs gets `403 forbidden`.

### Personal tokens

The [CLI](/docs/cli) signs in with a **personal token** (`rc_pat_…`) instead. It acts as its person in whichever of
their workspaces a request names, with their relation there (an org's owner administers each of its workspaces), never
more, and never at org level. A request that acts in a workspace names it:

```http
Authorization: Bearer rc_pat_…
Raw-Workspace: ws_01m6xxgde9f2c9q3y3gn9e8zdp
```

Without `Raw-Workspace`, such a request is `400 invalid_request`; `GET /v1/me`, `GET /v1/workspaces` and the
endpoints that take a workspace in their path need none. A workspace key may send `Raw-Workspace` too, naming its own
workspace; naming another is `403 forbidden`. `GET /v1/me` says which kind a token is (`api_key.kind`: `workspace` or
`personal`).

A personal token is made by the **device flow** ([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)), with no key:

1. `POST /v1/device/code` with `{ "client_name": "raw CLI on build-7" }` answers `device_code`, `user_code`
   (`WDJB-MJHT`), `verification_uri_complete`, `expires_in` (600) and `interval` (5).
2. The person opens `verification_uri_complete`, signed in to the console, checks the code and approves it.
3. `POST /v1/device/token` with `{ "device_code": "…" }`, every `interval` seconds, answers `400 authorization_pending`
   until then, and then `201` with the token (`secret`), once. A refusal is `400 access_denied`; a code past its ten
   minutes, unknown or used, `400 expired_token`.

A token is revoked with `DELETE /v1/api_keys/{id}`, by itself (`raw logout`) or by its person in the console.

## Versions

```http
Raw-Version: 2026-11-08
```

Versions are dates. A request without `Raw-Version` is answered in the version **its API key is pinned to**: the one
that was current on the day the key was made. Every answer says which version it was given in, in its own
`Raw-Version` header, and `GET /v1/me` returns it as `raw_version`. A version that does not exist is
`400 invalid_request`.

Additive changes (a new field, a new endpoint) need no version. A breaking change gets a new date, and the server keeps
older versions working through a shim for at least 24 months. A version on its way out will answer with `Deprecation`
and `Sunset` headers. Today there is one version, `2026-11-08`.

## Idempotency

Any `POST` may carry an `Idempotency-Key` header: a unique string of 1 to 255 characters; a UUID is fine. It is
optional, and it makes a retry safe.

```http
Idempotency-Key: 6f1c1a52-3a0e-4c35-a7e5-2b0d0e1f7c11
```

- Keys are scoped to the **API key** that sends them and kept for **24 hours**.
- A request is matched by a hash of its method, path and body. The same request with the same key is answered as the
  first one was, with the header `Idempotent-Replayed: true`, and nothing runs twice.
- A different request with the same key is `409 idempotency_conflict`. So is a second request sent while the first is
  still being answered; retry it in a moment.
- **Only successful (2xx) answers are kept.** Any error, 4xx or 5xx, releases the key, so a retry with the same key
  runs again. After a `402 insufficient_funds`, top up and retry the very same request.
- A replayed `POST /v1/api_keys` answers with `"secret": null`: the secret is shown once and never stored.

## Ids

Ids are typed by prefix, so a log line tells you what it names:

| Prefix        | Names                  | Form                                           |
| ------------- | ---------------------- | ---------------------------------------------- |
| `org_`        | an org                 | `org_` + 26 characters                         |
| `ws_`         | a workspace            | `ws_` + 26 characters                          |
| `key_`        | an API key             | `key_` + 26 characters                         |
| `usr_`        | a person               | `usr_` + the person's account id               |
| `inv_`        | an invitation          | `inv_` + 26 characters                         |
| `ent_`        | a ledger entry         | `ent_` + a number                              |
| `sbx_`        | a sandbox              | `sbx_` + its cell's code + `_` + 26 characters |
| `snp_`        | a snapshot             | `snp_` + 26 characters                         |
| `tpl_`        | a template             | `tpl_` + 26 characters                         |
| `op_`, `evt_` | an operation, an event | arrive with the endpoints that return them     |

The 26 characters are lower-case Crockford base32 (`0-9a-z` without `i`, `l`, `o`, `u`), and they sort in the order the
ids were made. Treat every id as an opaque string: compare it whole, never parse it.

## Lists, money and time

- A list is `{ "object": "list", "data": [ … ], "has_more": false }`. Lists that page take `limit` and
  `starting_after` (the last id of the page you have).
- Money is an integer number of **micro-dollars** in fields ending `_usd_micros`: `1000000` is $1.00.
- Times are RFC 3339 in UTC, such as `2026-11-08T17:02:11.000Z`; days are `YYYY-MM-DD` in UTC.

## Endpoints

| Method and path                  | What it answers                                                                             | Who may                                |
| -------------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------- |
| `GET /v1/me`                     | the key's person, the key, the workspace the request acts in and its org, and the version   | any key                                |
| `GET /v1/workspaces`             | the workspaces the key acts in (one, for a workspace key)                                   | any key                                |
| `GET /v1/workspaces/{id}`        | one workspace                                                                               | any key of it                          |
| `GET /v1/workspaces/{id}/budget` | the workspace's cap, spend this month, what is held, what is left, and `can_start`          | any key of it                          |
| `POST /v1/workspaces/{id}`       | renames it (`name`) or sets where its sandboxes may live (`residency`): see below           | an admin's token                       |
| `GET /v1/members`                | the key's workspace's members: its grants, and the org's owners as `admin` (`via`)          | any key                                |
| `POST /v1/members/{id}`          | sets a member's one relation: `{ "relation": "viewer" \| "developer" \| "admin" }`          | an admin's token                       |
| `DELETE /v1/members/{id}`        | removes a member: every grant of theirs in the workspace ends                               | an admin's token                       |
| `GET /v1/invitations`            | the workspace's invitations nobody accepted yet, oldest first                               | an admin's token                       |
| `POST /v1/invitations`           | mails an invitation: `{ "email", "relation"? }` (`developer` by default), valid seven days  | an admin's token                       |
| `DELETE /v1/invitations/{id}`    | withdraws a pending invitation; its link stops working                                      | an admin's token                       |
| `GET /v1/api_keys`               | the keys of the workspace that the key's person made; never a secret                        | any key                                |
| `POST /v1/api_keys`              | a new key of the same person, with its secret, once                                         | `developer` keys                       |
| `DELETE /v1/api_keys/{id}`       | revokes a key of the same person (a key may revoke itself)                                  | `developer` keys                       |
| `GET /v1/usage`                  | usage per UTC day, class and size; `from` and `to` (default: the last 30 days, at most 366) | any key                                |
| `GET /v1/wallet`                 | the org's balance and ledger entries, newest first; `limit` 1–100 (20), `starting_after`    | keys of an org owner or billing member |
| `GET /v1/sandboxes`              | the workspace's sandboxes, newest first; `limit` 1–200 (50), `starting_after`               | any key                                |
| `POST /v1/sandboxes`             | starts a sandbox: see below                                                                 | `developer` keys                       |
| `GET /v1/sandboxes/{id}`         | one sandbox, as its cell last reported it                                                   | any key                                |
| `POST /v1/sandboxes/{id}/stop`   | stops it, keeping its disk: see [Acting on a sandbox](#acting-on-a-sandbox)                 | `developer` keys                       |
| `POST /v1/sandboxes/{id}/start`  | starts it again from its disk                                                               | `developer` keys                       |
| `POST /v1/sandboxes/{id}/fork`   | a new sandbox from this one's disk and memory; body `{ "name"?, "on_full"? }`               | `developer` keys                       |
| `DELETE /v1/sandboxes/{id}`      | deletes it and its disk                                                                     | `developer` keys                       |
| `GET /v1/snapshots`              | the workspace's snapshots, newest first; `sandbox`, `limit` 1–200 (50), `starting_after`    | any key                                |
| `POST /v1/snapshots`             | snapshots a sandbox: see [Snapshots and templates](#snapshots-and-templates)                | `developer` keys                       |
| `GET /v1/snapshots/{id}`         | one snapshot                                                                                | any key                                |
| `DELETE /v1/snapshots/{id}`      | deletes it, freeing its unique bytes                                                        | `developer` keys                       |
| `GET /v1/templates`              | the templates the workspace may start from: its own and our public ones, by name            | any key                                |
| `GET /v1/templates/{id}`         | one template                                                                                | any key                                |
| `POST /v1/device/code`           | starts a sign-in of the CLI (device flow): see [Personal tokens](#personal-tokens)          | anyone, no key                         |
| `POST /v1/device/token`          | the sign-in's personal token, once it is approved                                           | anyone, with its `device_code`         |

Every endpoint that acts in "the workspace" acts in the key's own, or in the one a personal token names with
`Raw-Workspace`. "An admin's token" is a personal token whose person is an admin of the workspace, or an owner of its
org; a workspace key asking is `403 forbidden`, with `raw login` as its next step.

### A workspace's people and settings

An admin does with one call what the console's **Members** and **Settings** pages do:

```http
POST /v1/invitations
Authorization: Bearer rc_pat_…
Raw-Workspace: ws_01m6xxgde9f2c9q3y3gn9e8zdp
Idempotency-Key: invite-ada

{ "email": "ada@example.com", "relation": "developer" }
```

```json
{
	"object": "invitation",
	"id": "inv_01m6xxk2w9c4t7r5q3y8vzd6na",
	"email": "ada@example.com",
	"relation": "developer",
	"created_at": "2026-11-09T09:12:00.000Z",
	"expires_at": "2026-11-16T09:12:00.000Z"
}
```

The person gets a link by email and accepts it signed in to the console, as themselves; they are then a member, as
`GET /v1/members` lists them (`usr_…`). `POST /v1/members/{id}` makes the relation given their one relation in the
workspace, and `DELETE /v1/members/{id}` removes them (their keys of the workspace stop working). A workspace sends at
most a few invitations a minute, the console's and the API's together ([Limits](/docs/limits#rate-limits)); past it,
`429 rate_limited` says when to send again.

`POST /v1/workspaces/{id}` takes `name` (1 to 100 characters; its `code` stays), `residency` (`any`, `eu`, or `null` to
follow the org's default), or both, and answers the workspace; every workspace answer carries its `residency`.

An org's owners administer every workspace of it without a grant (`via: "org_owner"`): changing or removing one is
`403 forbidden`, with the console as the next step.

### Only in the console

Some things are done only by a person signed in to the console, because they act at org level, or need the person
themselves, and no key or token ever does (a token is never more than a workspace's member):

| What                                            | Where                 | Why not the API                                         |
| ----------------------------------------------- | --------------------- | ------------------------------------------------------- |
| Make a member an owner of the org               | **Members**           | ownership is the org's; a token never acts at org level |
| Rename the org                                  | **Settings**          | the org's                                               |
| Create a workspace                              | **Settings**          | an owner of the org makes it, at org level              |
| Set a monthly cap, the workspace's or the org's | **Billing**           | it is money: no key or token writes money               |
| Accept an invitation                            | the link in the email | the person invited accepts, signed in as themselves     |
| Delete an account                               | **Settings**          | only the person themselves, signed in                   |

Any key reads the result: `GET /v1/workspaces/{id}/budget` answers the caps and `can_start`, `GET /v1/members` the
owners.

### Money an agent can read

`GET /v1/wallet` is the org's money, so it answers only a key whose person is an owner or a billing member of the org
(read only: no key writes money). Every other key gets `403 forbidden`, with the budget as its next step:

```json
{
	"object": "budget",
	"workspace": "ws_01m6xxgde9f2c9q3y3gn9e8zdp",
	"monthly_cap_usd_micros": null,
	"spent_usd_micros": 0,
	"held_usd_micros": 0,
	"remaining_usd_micros": null,
	"can_start": false
}
```

`can_start` says whether the org's wallet and the caps leave room to start a sandbox now; when it is `false`, a create
answers `402 insufficient_funds`. A cap of `null` means the workspace has none of its own (the org's may still hold).

### Creating a sandbox

```json
{ "class": "std", "size": "4x8", "name": "build-42", "on_full": "queue" }
```

`class` is `std` or `perf`; `size` is one the class sells ([Limits](/docs/limits#sizes)); `name` is optional;
`template` (optional) is a `tpl_…` to start from, as `GET /v1/templates` lists it; `on_full` is `queue` (the default)
or `fail`. The checks run in this order:

1. The body and the size: `400 invalid_request` (for example `8x32 is not a size of the perf class.`).
2. The template, when one is named: `404 not_found` for one the workspace may not start from.
3. The key's relation: `403 forbidden` for a `viewer` key.
4. Capacity. When no cell serves the class in the workspace's residency, `503 capacity_unavailable`, whatever `on_full`
   says, with no `Retry-After`. **This is every create during early access.** When cells serve it but are full, `202`
   with the sandbox `queued`, or with `"on_full": "fail"`, `503 capacity_full` with `Retry-After`.
5. Money: `402 insufficient_funds` when `can_start` is false.

Once cells are open, a create that starts at once answers `201` with the sandbox; one that waits answers `202`:

```json
{
	"object": "sandbox",
	"id": "sbx_ny1_01m6xxh3k2q8v7c4t9r5wdz6ya",
	"name": "build-42",
	"class": "std",
	"size": "4x8",
	"state": "queued",
	"created_at": "2026-11-08T17:02:11.000Z",
	"queue": { "position": 3, "eta": "2026-11-08T17:04:00.000Z" }
}
```

A sandbox's `state` is `queued`, `starting`, `running`, `stopping`, `stopped` or `failed`, as its cell last reported
it, or `unknown` before its cell has reported.

### Acting on a sandbox

A sandbox id is `sbx_`, the code of the cell that holds it, `_`, and 26 characters, as `GET /v1/sandboxes` lists it.
Each of these reads the directory the cells write first: an id of another form, one in another workspace, a deleted
sandbox or one never made is `404 not_found`, with `GET /v1/sandboxes` as the next step. A `viewer` key may read a
sandbox but not act on it (`403 forbidden`).

Stopping, starting, forking and deleting are done by the sandbox's cell. **During early access no cell takes them
through the API**, so a sandbox the directory holds is answered `503 capacity_unavailable`, never with a state made up
on our side; the directory holds none yet, so today every id is `404`. Once cells are open, stop and start answer `202`
with the sandbox `stopping` or `starting`, a fork `201` or `202` like a create (paid and queued the same way), and a
delete `200` with `{ "object": "sandbox", "id", "deleted": true }`.

### Snapshots and templates

Snapshots and templates are read from the same directory the cells write, so they are listed the moment a cell reports
them. During early access no cell does, and the lists are empty.

```json
{
	"object": "snapshot",
	"id": "snp_01m6xxj8w4c2t7q9r3v5n6pz1k",
	"name": "before-upgrade",
	"sandbox": "sbx_ny1_01m6xxh3k2q8v7c4t9r5wdz6ya",
	"unique_size_bytes": 1342177280,
	"expires_at": null,
	"created_at": "2026-11-08T17:20:00.000Z"
}
```

- `GET /v1/snapshots?sandbox=sbx_…` lists one sandbox's snapshots, a deleted sandbox's too: a snapshot outlives its
  sandbox. A `sandbox` not of a sandbox's form is `400 invalid_request`; one the workspace does not hold, `404`.
- `unique_size_bytes` is what only this snapshot holds: what deleting it frees, and what its storage is billed on.
  `expires_at` is when its cell will delete it, or `null` when it is kept until someone does.
- A **template** is `{ "object": "template", "id", "name", "workspace", "cpu_baseline", "created_at" }`. `workspace`
  is the workspace whose template it is, or `null` for one of our public templates, which every workspace may start
  from. `cpu_baseline` (such as `zen5-v1`) is the CPU its memory image was made on. `GET /v1/templates` is one page
  (`has_more` is `false`).
- An id of another form, of another workspace, or of a deleted snapshot is `404 not_found`, with the list as its next
  step; a public template is found from every workspace.

Taking a snapshot (`POST /v1/snapshots` with `{ "sandbox": "sbx_…", "name"? }`), deleting one and starting a sandbox
from a template are done by cells, like [acting on a sandbox](#acting-on-a-sandbox): **during early access** one the
directory holds is answered `503 capacity_unavailable`, anything else `404`, and a `viewer` key `403`. Once cells are
open, a snapshot answers `202` with the snapshot and a delete `200` with `{ "object": "snapshot", "id", "deleted": true }`.

### Coming with the first cells

Long operations (`?wait=ready` long-polls, `GET /v1/operations/{id}`); events (`GET /v1/events?stream=1` as server-sent
events, and signed webhooks). Each appears in the OpenAPI document when it is answered, not before.

## Errors

Every error has one shape:

```json
{
	"error": {
		"code": "insufficient_funds",
		"message": "The wallet or a budget cap cannot pay for a new sandbox in this workspace.",
		"next_step": {
			"message": "Top up the wallet or raise the cap, then retry the same request with the same Idempotency-Key.",
			"url": "https://raw.computer/app/billing",
			"api": "GET /v1/workspaces/ws_01m6xxgde9f2c9q3y3gn9e8zdp/budget"
		},
		"docs": "https://raw.computer/docs/errors#insufficient_funds"
	}
}
```

- `code` is stable; branch on it, never on `message`.
- `message` says what happened, in one sentence.
- `next_step.message` is always there: one plain sentence a person or a model can act on.
- `next_step.url`, `next_step.api` (as `METHOD /v1/path`) and `next_step.cli` (a [`raw`](/docs/cli) command, such as
  `raw login`) are optional, machine-usable hints.
- `retry_at` (RFC 3339) appears beside a `Retry-After` header when a later call can succeed: on `rate_limited`
  ([Limits › Rate limits](/docs/limits#rate-limits)) and `capacity_full`.
- `docs` links to the code on the [Errors](/docs/errors) page, which lists all ten codes.
