# The API

The Sandbox Router 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.sandboxrouter.io/v1`, the `sandboxrouter` 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.sandboxrouter.io/), 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 SANDBOXROUTER_API_KEY=<api-key>

curl https://api.sandboxrouter.io/v1/sandboxes \
  -H "Authorization: Bearer $SANDBOXROUTER_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, metrics 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 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: 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 Sandbox Router reports sit side by side. An unknown field is refused, naming it.
- A path's `<sandbox>` is a sandbox's ID, 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": "idleStopMinutes must be a whole number from 5 to 240", "field": "idleStopMinutes" } }
```

`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 what it asked for: no card on file, a balance of $0 or below, or this month's spend cap reached. Creating or starting a sandbox answers `out_of_funds` for each of them, within an hour of the change |
| `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, or an idempotency key was reused with another body |
| `name_taken` | `409` | The name belongs to another server or schedule |
| `not_running` | `409` | An exec, a file transfer or another call that needs the sandbox running, for one that doesn't run |
| `provider_feature_unavailable` | `409` | What the sandbox's provider doesn't offer, named in the message, such as an exec's `stdin`, a snapshot or a port's URL |
| `provider_unavailable` | `503` | The sandbox's provider, or under `auto` every provider of its size, couldn't run it now; with `Retry-After` when the provider said how long |
| `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. |

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-sandboxrouter-request-id` header that names the request. Sandbox Router 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 | 6,000 a minute |
| To the API, with one API key | 6,000 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.sandboxrouter.io/v1/sandboxes \
  -H "Authorization: Bearer $SANDBOXROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: build-agent-0001" \
  -d '{
    "image": "default",
    "size": "l",
    "env": { "MODE": "ci" },
    "egress": ["api.github.com", "*.npmjs.org"],
    "labels": { "name": "build-agent", "team": "ci" },
    "connectors": ["github"]
  }'
```

`{ "image": "default" }` alone is a whole request. The request holds until the sandbox runs, and answers `201 Created` with it:

```json
{
  "id": "u-pjcusquzsv73qilmgdi6pmwfqhlyg7cn5xiehulebdf32cv42qsq",
  "image": "default",
  "command": null,
  "env": { "MODE": "ci" },
  "size": "l",
  "allocation": { "cpu": 1, "memoryGiB": 6, "diskGB": 12 },
  "egress": ["api.github.com", "*.npmjs.org"],
  "idleStopMinutes": 15,
  "labels": { "name": "build-agent", "team": "ci" },
  "providerLabels": null,
  "provider": "auto",
  "user": null,
  "connectors": ["github"],
  "providerId": "sb-4f9a0c1d2e3b",
  "currentProvider": "cloudflare",
  "state": "running",
  "stateMessage": null,
  "createdAt": 1791797400000,
  "startedAt": 1791797403000,
  "stoppedAt": null
}
```

A create that failed answers `failed`, with the reason in `stateMessage`. Sandbox Router sets no deadline of its own on it: how long to wait is yours to decide.

### Sandbox routes

| Route | Scope | What it does |
| --- | --- | --- |
| `POST /v1/sandboxes` | `sandbox:write` | Creates a sandbox |
| `GET /v1/sandboxes` | `sandbox:read` | Lists sandboxes, newest first, each without its `env`: `{ sandboxes, nextCursor }`. Filter with `?label=key=value` (repeat it) and `?provider=`; `?limit=` 1 to 100, 50 by default. A new sandbox shows within seconds. |
| `GET /v1/sandboxes/<sandbox>` | `sandbox:read` | Reads one; `?waitMs=` waits that long for a sandbox that's starting or stopping to settle |
| `PATCH /v1/sandboxes/<sandbox>` | `sandbox:write` | Replaces `egress`, `idleStopMinutes` or `labels` |
| `DELETE /v1/sandboxes/<sandbox>` | `sandbox:write` | Deletes it, with its provider's snapshots of it |
| `POST …/start`, `…/stop`, `…/restart` | `sandbox:write` | Starts, stops, or stops and starts it, holding until it settles |
| `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 …/metrics` | `sandbox:read` | Its CPU, memory and disk over time, as its provider measured them; see [Metrics](#metrics) |
| `GET`, `PUT`, `DELETE …/files/<path>` | `sandbox:execute` | Its files; see [Files](#files) |
| `GET …/ports/<port>` | `sandbox:read` | Its provider's own URL for a port while it runs: `{ "port", "url" }` |
| `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 |

`…` is `/v1/sandboxes/<sandbox>`. A stopped sandbox stays stopped until `…/start`: an exec or a file transfer sent to it gets `409 not_running`.

### Execs

```sh
curl https://api.sandboxrouter.io/v1/sandboxes/<sandbox>/exec \
  -H "Authorization: Bearer $SANDBOXROUTER_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 request holds until the command ends and answers `200`, or `202` at once with `"detach": true`:

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

`state` is `running`, `succeeded`, `failed`, `timed_out`, `canceled` or `lost`; `stdout` and `stderr` are `null` until it ends, then the whole output. A provider that can't take an exec's `stdin`, `env`, `workdir` or time limit refuses it with `provider_feature_unavailable`.

### Logs

`GET /v1/sandboxes/<sandbox>/logs` answers `{ "entries": [{ "at", "stream", "message", "kind" }] }`: every entry at or after `?since=` (milliseconds or an ISO 8601 time), oldest first, kept for the sandbox's lifetime. Each is what Sandbox Router recorded of the sandbox (`stream` `system`): a state it reached, as its `kind`, with why. What its command writes is its provider's. 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`.

### Metrics

`GET /v1/sandboxes/<sandbox>/metrics` answers `{ "samples": [{ "at", "cpuPct", "memUsedBytes", "memTotalBytes", "diskUsedBytes", "diskTotalBytes" }] }`: the sandbox's provider's own measurements of its CPU (as a percentage of all its CPUs), memory and disk (in bytes), oldest first, from `?from=` to `?to=` (each in milliseconds; without `from`, from the sandbox's create, and without `to`, until now), as far back as the provider keeps them. A value the provider doesn't measure is `null`. A provider that keeps no metrics refuses them with `provider_feature_unavailable`, and a `from` after `to` (or a `to` before the sandbox's create, without `from`) is `invalid_request`.

### Files

| Route | What it does |
| --- | --- |
| `GET …/files/<path>` | A file's bytes as `application/octet-stream` |
| `PUT …/files/<path>` | Writes the body as the file, whole: `{ "path", "bytes" }` |
| `DELETE …/files/<path>` | Deletes the file |

Each is the provider's own file operation on the running sandbox. `<path>` is the absolute path with each segment percent-encoded, as in `/v1/sandboxes/<sandbox>/files/workspace/my%20file.txt`; `…/files/` is `/`, which can only be read. Paths under `/proc`, `/sys` and `/dev` are refused.

### Snapshots

A snapshot is the provider's own. `GET …/snapshots` answers `{ "snapshots": [{ "id", "image", "memory", "createdAt" }] }`, newest first. `POST …/snapshots` with `{}` has the provider take one of the running sandbox: `{ "snapshot" }`. `DELETE …/snapshots/<snapshot-id>` deletes one.

## 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.sandboxrouter.io/v1/mcp-servers \
  -H "Authorization: Bearer $SANDBOXROUTER_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.sandboxrouter.io/<id>`), the `sandboxId` 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 and browser time 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.sandboxrouter.io/v1/users/user_123/connections \
  -H "Authorization: Bearer $SANDBOXROUTER_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 its create names, and their logins in its browsers (see [Logins](/docs/logins#use-the-api)). Codex (ChatGPT)'s users sign in with a code instead, from `POST /v1/users/<user>/connections/<connector>/sign-in`, made before a sandbox of theirs needs it; see [Sign users in with a code](/docs/connectors#sign-users-in-with-a-code).

```sh
curl https://api.sandboxrouter.io/v1/connectors \
  -H "Authorization: Bearer $SANDBOXROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "catalog": "openai" }'

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

A sandbox gets a connector when its create names it (`"connectors": ["openai"]`). Sandboxes without a user then use the workspace's account. A connector's `selector` picks the workflows' sandboxes it reaches by their labels (`{}`, every one). 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.sandboxrouter.io/v1/billing/usage?month=2026-10" \
  -H "Authorization: Bearer $SANDBOXROUTER_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) 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.
