# The API

The isolance API lets you automate sandboxes, MCP servers, connectors, your users' logins and billing with an API key. It's a JSON API over HTTPS at `https://api.caas.sh/v1`, the `caas` CLI ([The CLI](/docs/cli)) maps its commands onto it one to one, and the TypeScript SDK ([The TypeScript SDK](/docs/sdk)) offers it as typed methods.

This guide covers authentication, the conventions every route follows, and the routes for each resource. The other guides explain what each resource does.

## Authenticate

### Create an API key

1. In the [console](https://console.isolance.com/), open **Settings**.
2. In the **API keys** card, enter a **Key name**, such as `ci`, and choose **Create key**. If you're an owner, you can also check **Billing access**, which lets the key read the balance and add funds.
3. Copy the key. It starts with `ck_`, and it isn't shown again.

Owners and operators can create keys; viewers can't. A key from the console doesn't expire: it works until you choose **Revoke** beside it. Keys, members and invitations are managed only in the console, never with an API key.

### Send the key

Send the key as a bearer token:

```sh
export CAAS_API_KEY=<api-key>

curl https://api.caas.sh/v1/sandboxes \
  -H "Authorization: Bearer $CAAS_API_KEY"
```

A key belongs to one workspace, and every request it sends acts in that workspace: no path names a workspace. A sandbox, server or connector of another workspace, by ID or name, answers `404`, as one that doesn't exist does. To work in another workspace, use a key created there.

### Scopes

A key's scopes say which routes it may call. A key from the console has every scope but the two billing ones, and **Billing access** adds those.

| Scope | What it allows |
| --- | --- |
| `sandbox:read` | Read sandboxes, their execs, logs and snapshots; connectors, your users and their usage; the workspace's settings and audit log |
| `sandbox:write` | Create, change, start, stop and delete sandboxes; take, restore and delete snapshots; change the workspace's settings; make connect links, get and see your users' sign-in codes, disconnect and remove your users; change connectors and their workspace accounts (owners only); add, change and delete schedules, and run them now (with `sandbox:execute`) |
| `sandbox:execute` | Run and cancel execs; read, write and delete files |
| `mcp:read` | Read MCP servers, their client tokens, traces and stats |
| `mcp:write` | Create, change and delete MCP servers (with `sandbox:write`, which a delete that leaves the server's sandbox doesn't need), and mint and revoke client tokens |
| `registry:read` | Pull images and list tags at `registry.caas.sh` |
| `registry:write` | Push and delete images at `registry.caas.sh`, and pull them |
| `billing:read` | Read the balance, usage and auto-recharge, and see billing records in the audit log |
| `billing:write` | Add funds and change auto-recharge |

### Roles

A key can do what its scopes allow, within the role its creator has in the workspace now:

| Role | Scopes the role allows |
| --- | --- |
| Owner | All of them |
| Operator | All but `billing:read` and `billing:write` |
| Viewer | `sandbox:read`, `mcp:read`, `registry:read` and `billing:read` |

So if an operator who created a key becomes a viewer, the key can only read, and if they leave the workspace, the key gets `403`. Adding, changing and removing connectors, and connecting or disconnecting their workspace accounts, needs an owner's key.

A revoked key, or a changed role, takes effect within a minute. Requests that grant access check the key at once instead: creating a sandbox without a user, changing a sandbox's labels, minting MCP client tokens, creating an MCP server, every change of a connector or its workspace account, and adding funds or changing auto-recharge.

## Requests and responses

- Send request bodies as JSON, with `Content-Type: application/json`, except for [files](#files). A body larger than its route takes gets `413`.
- Objects are flat: what you set and what isolance reports sit side by side. An unknown field is refused, naming it.
- A path's `<sandbox>` is a sandbox's ID or its name, and `<server>` an MCP server's ID or name.
- Times are milliseconds since the Unix epoch. Money is in micro-USD: 1,000,000 is $1.
- A list answers `{ "<resource>": [...] }`, and a paged one its `nextCursor` beside it: pass it back as `?cursor=` for the next page. It's `null` on the last page.
- Deleting a sandbox or a snapshot answers `{ "id": "…", "deleted": true }`. An MCP server's answer adds `"sandboxDeleted"`, whether its sandbox went with it; a connector's adds how many users' `connections` and whether an `account` went with it; a file answers `{ "path": "…", "deleted": true }`. Revoking a client token answers the token.

## Errors

Every error has the same JSON body:

```json
{ "error": { "code": "invalid_request", "message": "ports[0]: port must be from 1024 to 65535", "field": "ports" } }
```

`field` names the request field a validation error is about; a nested one is dotted, as in `sandbox.size`. Other fields some errors carry sit inside `error`: a billing refusal's `billingUrl`, a `5xx`'s `requestId`.

| `code` | Status | Meaning |
| --- | --- | --- |
| `invalid_request` | `400` | A field, query or header the API refuses, or a body that isn't JSON |
| `unauthorized` | `401` | No API key, or one that's unknown or revoked |
| `payment_method_required`, `out_of_funds`, `spend_cap_reached` | `402` | The workspace can't run sandboxes: no card on file, a balance of $0 or below, or this month's spend cap reached |
| `forbidden` | `403` | The key's scopes or its creator's role don't allow the request |
| `tenant_suspended` | `403` | The workspace is suspended. Billing still works, so an owner can pay. |
| `not_found` | `404` | No such route, sandbox, server, exec, file, snapshot or connector |
| `method_not_allowed` | `405` | The route doesn't take this method; the `Allow` header lists the ones it does |
| `conflict` | `409` | The request conflicts with the resource's state, such as restoring a snapshot into a running sandbox, or an idempotency key reused with another body |
| `name_taken` | `409` | The name belongs to another sandbox or server |
| `not_running` | `409` | An exec or a file transfer for a sandbox you stopped yourself |
| `too_large` | `413` | The body is too large |
| `unsupported_media_type` | `415` | A body whose `Content-Type` the route doesn't take |
| `rate_limited` | `429` | A rate limit was reached; see [Rate limits](#rate-limits) |
| `unavailable` | `502`, `503` | A temporary failure. Try again shortly. |
| `timeout` | `504` | A file transfer or snapshot is still under way. Check again shortly. |

Every `402` has a `billingUrl`: the console page where an owner adds funds or a card. See [Billing and usage](/docs/billing).

## Request IDs

Every response has an `x-caas-request-id` header that names the request. isolance logs everything the request did under that ID, and the audit log records it with each change. Quote it when you report a problem.

## Rate limits

| Requests | Limit |
| --- | --- |
| To the API, from one client address | 600 a minute |
| To the API, with one API key | 600 a minute |
| To one MCP endpoint, with a valid client token | 1,200 a minute |
| To the MCP gateway without a valid client token, from one client address | 600 a minute |

A request over a limit gets `429` with `"code": "rate_limited"` and a `Retry-After` header. Limits are counted at each edge location separately.

## Idempotency keys

Requests that create something you mustn't create twice take an `Idempotency-Key` header of 8 to 128 letters, digits, `_` and `-`. If you get no answer, or a `5xx`, send the request again with the same key and body. If the first one went through, you get its result, and nothing is created twice.

| Request | A repeat with the same key | With another body |
| --- | --- | --- |
| `POST /v1/sandboxes` | `200` with the same sandbox | `409` |
| `POST /v1/sandboxes/<sandbox>/exec` | That exec; the key is its ID | `409` |
| `POST /v1/mcp-servers` | `200` with the server, without its tokens' secrets | `409` |
| `POST /v1/billing/top-ups` (required) | The same top-up | `409` |

Without the header, each request creates something new.

## Sandboxes

See [Sandboxes](/docs/sandboxes) for every field and what it does.

### Create a sandbox

```sh
curl https://api.caas.sh/v1/sandboxes \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: build-agent-0001" \
  -d '{
    "name": "build-agent",
    "image": "default",
    "size": "l",
    "env": { "MODE": "ci" },
    "ports": [{ "port": 3000, "public": true }],
    "egress": ["api.github.com", "*.npmjs.org"],
    "labels": { "team": "ci" }
  }'
```

`{ "image": "default" }` alone is a whole request. The answer is `201 Created` with the sandbox, which starts on its own:

```json
{
  "id": "7c9e4a2b-1f3d-4b6e-8a5c-3d2f1e0b9a87",
  "name": "build-agent",
  "image": "default",
  "command": null,
  "env": { "MODE": "ci" },
  "workdir": "/workspace",
  "size": "l",
  "volumes": [{ "path": "/workspace" }],
  "ports": [{ "port": 3000, "public": true }],
  "egress": ["api.github.com", "*.npmjs.org"],
  "user": null,
  "idleStopMinutes": 15,
  "labels": { "team": "ci" },
  "connectors": [{ "id": "github", "name": "GitHub", "hosts": ["api.githubcopilot.com", "api.github.com", "github.com"], "account": "workspace" }],
  "provider": "cloudflare",
  "providerId": null,
  "state": "creating",
  "stateMessage": null,
  "urls": [],
  "pendingRestart": [],
  "createdAt": 1791797400000,
  "startedAt": null,
  "stoppedAt": null,
  "usage": { "startedAt": null, "costPerHourMicroUsd": 0, "today": { "microUsd": 0, "egressBytes": 0 }, "lifetimeMicroUsd": 0 },
  "signIn": null
}
```

`state` moves through `creating`, `starting` and `running`; read the sandbox until it runs. `urls` list the provider's own URL of each port it gives one for, while the sandbox runs (see [URLs](/docs/sandboxes#urls)). A failed start leaves it `failed`, with the reason in `stateMessage`. `signIn` is `null` unless the sandbox is one of your users' and needs a sign-in they make with a code, such as Codex (ChatGPT)'s, which they haven't made: then it's the code they enter, which `…/start` and `…/restart` answer too (see [Sign users in with a code](/docs/connectors#sign-users-in-with-a-code)).

### Sandbox routes

| Route | Scope | What it does |
| --- | --- | --- |
| `POST /v1/sandboxes` | `sandbox:write` | Creates a sandbox |
| `GET /v1/sandboxes` | `sandbox:read` | Lists sandboxes, newest first: `{ sandboxes, nextCursor }`. Filter with `?label=key=value` (repeat it) and `?provider=`; `?limit=` 1 to 100, 50 by default. |
| `GET /v1/sandboxes/<sandbox>` | `sandbox:read` | Reads one |
| `PATCH /v1/sandboxes/<sandbox>` | `sandbox:write` | Replaces the fields it names; `pendingRestart` lists what waits for the next start |
| `DELETE /v1/sandboxes/<sandbox>` | `sandbox:write` | Deletes it, with its volumes and snapshots |
| `POST …/start`, `…/stop`, `…/restart` | `sandbox:write` | Starts, stops, or stops and starts it: `200` once done within about 25 seconds, else `202` while it changes |
| `POST …/exec` | `sandbox:execute` | Runs a command; see [Execs](#execs) |
| `GET …/exec/<exec-id>` | `sandbox:read` | Reads an exec |
| `DELETE …/exec/<exec-id>` | `sandbox:execute` | Cancels a running exec |
| `GET …/logs` | `sandbox:read` | Its log; see [Logs](#logs) |
| `GET`, `PUT`, `DELETE …/files/<path>` | `sandbox:execute` | Its files; see [Files](#files) |
| `GET …/snapshots`, `POST …/snapshots` | `sandbox:read`, `sandbox:write` | Lists snapshots, or takes one; see [Snapshots](#snapshots) |
| `DELETE …/snapshots/<snapshot-id>` | `sandbox:write` | Deletes a snapshot |
| `POST …/restore` | `sandbox:write` | Restores a snapshot into a stopped sandbox |

`…` is `/v1/sandboxes/<sandbox>`.

### Execs

```sh
curl https://api.caas.sh/v1/sandboxes/build-agent/exec \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: run-tests-0001" \
  -d '{ "command": ["npm", "test"], "env": { "CI": "1" }, "workdir": "/workspace/app", "timeoutSeconds": 1800 }'
```

| Field | Value |
| --- | --- |
| `command` | Required. 1 to 32 arguments of up to 512 characters; no shell is added |
| `stdin` | Text sent to the command, up to 64 KiB |
| `env` | Variables over the sandbox's |
| `workdir` | Its directory; the sandbox's by default |
| `timeoutSeconds` | 1 to 7,200; 600 by default. Up to 86,400 when detached |
| `detach` | `true` answers at once while it runs |

The answer is `200` once the command ended (within about five minutes), or `202` while it runs:

```json
{ "execId": "run-tests-0001", "state": "succeeded", "detach": false, "createdAt": 1791797400000, "endedAt": 1791797412000,
  "exitCode": 0, "signal": null, "stdout": "…", "stderr": "", "stdoutTruncated": false }
```

`state` is `submitting`, `running`, `succeeded`, `failed`, `timed_out`, `canceled` or `lost`; `stdout` and `stderr` are `null` until it ends, then up to 1 MiB each. A command sent to a sandbox that stopped while idle starts it first.

### Logs

`GET /v1/sandboxes/<sandbox>/logs` answers `{ "entries": [{ "at", "stream", "message", "kind" }] }`: the newest 1,000 entries at or after `?since=` (milliseconds or an ISO 8601 time), oldest first. `stream` is `system` for what isolance recorded, with its `kind`, or `stdout` and `stderr` for what the sandbox's command wrote. With `?follow=true`, the answer is newline-delimited JSON (`application/x-ndjson`): those entries, then each new one as it comes, for up to 10 minutes; follow on with `since`.

### Files

| Route | What it does |
| --- | --- |
| `GET …/files/<path>` | A file's bytes as `application/octet-stream` (`?tail=N` for its last N bytes), or a directory's listing as JSON: `{ "path", "entries": [{ "name", "type", "size" }] }`, up to 1,000 entries |
| `PUT …/files/<path>` | Writes the body as the file, up to 100 MiB, creating its directories: `{ "path", "bytes" }`. With `?extract=true`, the body is a gzipped tar unpacked into the directory `<path>`. |
| `DELETE …/files/<path>` | Deletes the file; a directory needs `?recursive=true` |

`<path>` is the absolute path with each segment percent-encoded, as in `/v1/sandboxes/build-agent/files/workspace/my%20file.txt`; `…/files/` is `/`, which can only be read. Paths under `/proc`, `/sys`, `/dev` and `/workspace/.caas` are refused.

```sh
curl -X PUT "https://api.caas.sh/v1/sandboxes/build-agent/files/workspace?extract=true" \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/gzip" \
  --data-binary @project.tar.gz
```

### Snapshots

`GET …/snapshots` answers `{ "snapshots": [{ "id", "name", "kind", "createdAt", "size", "current" }], "storedBytes" }`, newest first. `POST …/snapshots` with an optional `{ "name": "before-upgrade" }` takes one of a running sandbox: `{ "snapshot" }`. `POST …/restore` with `{ "snapshot": "<id or manual snapshot name>" }` restores one into a stopped or failed sandbox and starts it, answering the sandbox and `restoredSnapshotId`.

## MCP servers

| Route | Scope | What it does |
| --- | --- | --- |
| `POST /v1/mcp-servers` | `mcp:write`, `sandbox:write` | Creates a server, in a new sandbox or one you have |
| `GET /v1/mcp-servers` | `mcp:read` | Lists servers: `{ mcpServers, nextCursor }` |
| `GET /v1/mcp-servers/<server>` | `mcp:read` | Reads one |
| `PATCH /v1/mcp-servers/<server>` | `mcp:write`, `sandbox:write` | Renames it, or changes its port and path |
| `DELETE /v1/mcp-servers/<server>` | `mcp:write`; `sandbox:write` too when it deletes the server's sandbox | Deletes it, and the sandbox its create made unless `?keepSandbox=true` |
| `GET /v1/mcp-servers/<server>/tokens` | `mcp:read` | Lists its client tokens, never a secret |
| `POST /v1/mcp-servers/<server>/tokens` | `mcp:write` | Mints a token: `{ "label", "expiresAt", "allowedTools" }`; the answer has its `secret`, once |
| `DELETE /v1/mcp-servers/<server>/tokens/<token-id>` | `mcp:write` | Revokes a token |
| `GET /v1/mcp-servers/<server>/traces` | `mcp:read` | Its newest 100 requests |
| `GET /v1/mcp-servers/<server>/stats` | `mcp:read` | Its gateway's last 24 hours |

```sh
curl https://api.caas.sh/v1/mcp-servers \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "search-mcp",
    "sandbox": { "image": "registry.caas.sh/<tenant-id>/search-mcp@sha256:…", "egress": ["api.search.example.com"] },
    "transport": { "http": { "port": 8080 } },
    "tokens": [{ "label": "laptop-claude", "allowedTools": ["search.query"] }]
  }'
```

| Field | Value |
| --- | --- |
| `name` | Required. 1 to 63 lowercase letters, digits and hyphens, unique among the workspace's servers |
| `sandbox` | A new sandbox, exactly as `POST /v1/sandboxes` takes it |
| `sandboxId` | Instead of `sandbox`: an existing sandbox, by ID or name. It hosts at most one server. |
| `transport` | `{ "http": { "port": 3000, "path": "/mcp" } }`: the port the sandbox's server serves MCP over HTTP on, and its path (`/mcp` by default) |
| `tokens` | The client tokens to mint, up to 20; one **Initial client** token without it |

The answer, `201`, is the server: its `id`, its endpoint `url` (`https://mcp.caa.sh/<id>`), the `sandboxId` and `sandboxName` it runs in, `transport`, `state`, `stats`, and `tokens`, each minted token with its `secret`. See [MCP servers](/docs/mcp) for the endpoint, tokens and traces.

## Connectors

| Route | Scope | What it does |
| --- | --- | --- |
| `GET /v1/connector-catalog` | `sandbox:read` | Lists the catalog's tools, each with `available`, `reason` and `added` |
| `POST /v1/connectors/discover` | `sandbox:write`, owner | Says how an MCP server's users would connect: `{ "mcpUrl" }`. Nothing is kept. |
| `POST /v1/connectors` | `sandbox:write`, owner | Adds a connector: `{ "catalog" }`, `{ "mcpUrl", "auth"?, "app"? }` or `{ "api": { "hosts" }, "app"? }`, with `id`, `name`, `selector`, `envVar` and, for GitHub, `githubApp` optional |
| `GET /v1/connectors`, `GET /v1/connectors/<id>` | `sandbox:read` | Lists connectors, or reads one, with its workspace `account` |
| `PATCH /v1/connectors/<id>` | `sandbox:write`, owner | Changes its `name`, `selector`, `envVar`, `app` or `githubApp` |
| `DELETE /v1/connectors/<id>` | `sandbox:write`, owner | Removes it with its workspace account and its users' connections |
| `POST /v1/connectors/<id>/account` | `sandbox:write`, owner | Connects its workspace account: `{ "method": "key", "key" }`, or `sign_in` or `install`, whose next step is in `action`, or `device`, whose code is in `signIn` |
| `POST /v1/connectors/<id>/account/callback` | `sandbox:write`, owner | Finishes a sign-in that came back to the console: `{ "session", "confirm" }` |
| `DELETE /v1/connectors/<id>/account` | `sandbox:write`, owner | Disconnects its workspace account |
| `GET /v1/connectors/<id>/connections` | `sandbox:read` | Its users' connections: `{ connections, next }` |
| `GET /v1/users` | `sandbox:read` | Your users, with their connections' statuses, their logins and this month's usage: `{ users, next }` |
| `GET /v1/users/<user>`, `DELETE /v1/users/<user>` | `sandbox:read`, `sandbox:write` | Reads a user with their connections, or removes them with their connections, logins and usage |
| `GET /v1/users/<user>/usage` | `sandbox:read` | Their sandbox time, browser time and requests per UTC day: `?from=` and `?to=`, `YYYY-MM-DD` |
| `POST /v1/users/<user>/connections` | `sandbox:write` | A connect link: `{ "connector", "returnUrl"? }`, or the user's key: `{ "connector", "key" }` |
| `POST /v1/users/<user>/connections/<connector>/sign-in` | `sandbox:write` | The user's code for Codex (ChatGPT): `{ connection, signIn }`, the code under way while it still works, else a new one; for a user who's signed in, a sign-in again |
| `GET /v1/users/<user>/connections/<connector>`, `DELETE …` | `sandbox:read`, `sandbox:write` | Reads one connection, with its code's `signIn`, or disconnects the user |
| `GET`, `POST /v1/users/<user>/logins`, `GET`, `PATCH`, `DELETE …/logins/<login>` | `sandbox:read`, `sandbox:write` | The user's logins: list (`{ logins, nextCursor }`), add, read, change and remove one |
| `…/logins/<login>/passkeys/<passkey>`, `…/session`, `…/live`, `…/live/done`, `…/check` | `sandbox:read`, `sandbox:write` | Rename or remove a passkey; read, upload or remove a saved sign-in; open, finish or end a live view; check whether the site is still signed in |
| `POST /v1/users/<user>/logins/<login>/link`, `POST /v1/users/<user>/logins/link` | `sandbox:write` | A link for the user to sign in to a site: `{ login, connectUrl }` |
| `/v1/connectors/<id>/account/logins…` | `sandbox:read`; changes an owner | Your workspace's logins, with the same routes as a user's but the link |

`<user>` is your ID for the user, URL-encoded: `user_123`, or `auth0%7Cabc` for `auth0|abc`. The two lists take `?limit=` (1 to 100, 50 by default) and `?after=`, from the answer's `next`.

```sh
curl https://api.caas.sh/v1/users/user_123/connections \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "connector": "linear", "returnUrl": "https://app.acme.com/settings" }'
```

The answer's `connectUrl` is the link to send the user. A sandbox created with `"user": "user_123"` then uses their accounts for the connectors that reach it, and their logins in its browsers (see [Logins](/docs/logins#use-the-api)). Codex (ChatGPT)'s users sign in with a code instead, which a Codex sandbox's start answers when it's needed; see [Sign users in with a code](/docs/connectors#sign-users-in-with-a-code).

```sh
curl https://api.caas.sh/v1/connectors \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "catalog": "openai", "selector": { "team": "ci" } }'

curl https://api.caas.sh/v1/connectors/openai/account \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "method": "key", "key": "sk-..." }'
```

A sandbox reaches a connector when it has every label of the connector's `selector` (`{}`, every sandbox). Sandboxes without a user then use the workspace's account; a sandbox's `connectors` lists what it got. See [Connectors](/docs/connectors).


## Billing

Billing routes need a key with **Billing access**, which only an owner can create. They work while the workspace is suspended.

| Route | Scope | What it does |
| --- | --- | --- |
| `GET /v1/billing/balance` | `billing:read` | `{ balanceMicroUsd, autoRecharge, card, topUps, entries, nextCursor }`: the balance, auto-recharge, the card on file, recent top-ups with their receipts, and the balance's history, 60 entries a page |
| `GET /v1/billing/usage` | `billing:read` | A month's usage report: `?month=2026-10`, the current UTC month by default. Add `&format=csv` for the month as a CSV file. |
| `GET /v1/billing/auto-recharge` | `billing:read` | `{ enabled, minimumMicroUsd, targetMicroUsd, active, failure }` |
| `PUT /v1/billing/auto-recharge` | `billing:write` | Changes any of `enabled`, `minimumMicroUsd` ($5 to $1,000) and `targetMicroUsd` (at least $5 more, up to $2,000) |
| `POST /v1/billing/top-ups` | `billing:write` | Adds funds, `{ "amountMicroUsd": 25000000 }` ($5 to $2,000 of credits, in whole cents), with a required idempotency key. The card is charged the credits; the top-up's `feeMicroUsd` is 0 (only a top-up made from October 20, 2026 until the fee's removal can carry a fee) |

```sh
curl "https://api.caas.sh/v1/billing/usage?month=2026-10" \
  -H "Authorization: Bearer $CAAS_API_KEY"
```

The usage report has a `lines` entry for each meter, with its `quantity`, `unit` and `amountMicroUsd`, the month's `totalMicroUsd`, a total for each UTC day in `daily`, and, while the month is under way, a `projection` for the whole month. The CSV has one row per UTC day and meter: `day,meter,quantity,unit,rate_usd,amount_usd`.

Adding funds charges the card on file and answers `{ "topUp": { … }, "url": null }`; the balance goes up once the payment is confirmed. Without a card, or with `"checkout": true`, `url` is a Stripe Checkout page to finish the payment in. A declined card gets `402` with the failed `topUp`. See [Billing and usage](/docs/billing), and [pricing](/pricing.html) for the rates.

## Settings and the audit log

| Route | Scope | What it does |
| --- | --- | --- |
| `GET /v1/settings` | `sandbox:read` | `{ "timeZone": "UTC" }` |
| `PATCH /v1/settings` | `sandbox:write` | Sets the time zone, as an IANA name: `{ "timeZone": "Europe/Berlin" }` |
| `GET /v1/audit-log` | `sandbox:read` | The audit log, newest first: `{ records, nextCursor, retentionDays }` |

`GET /v1/audit-log` takes these filters, each at most once:

| Parameter | Value |
| --- | --- |
| `action` | An action, such as `sandbox.stop`, or a group, such as `sandbox`, `command` or `mcp_server` |
| `actor` | A user's or API key's ID, a sandbox's ID (what its AI did), one of your users' IDs (what they did through a link), `operator` or `system` |
| `target` | An ID, such as a sandbox's |
| `outcome` | `success` or `failure` |
| `since`, `until` | Times in milliseconds since the Unix epoch |
| `limit` | 1 to 100 records; 50 by default |
| `cursor` | The previous page's `nextCursor` |

Each record has its time (`at`), `actor`, `action`, `target`, `outcome`, HTTP `status`, `requestId` and a short `detail`, never a secret, a token or a command's text. Records are kept for 365 days. An API key never sees records about API keys, members or invitations, sees billing records only with `billing:read`, and sees a user who made a change by ID, without their email.
