# The API

The caas.sh API lets you automate sandboxes, MCP servers, workflow runs, credentials and billing with an API key. It's a JSON API over HTTPS at `https://api.caas.sh`, and the `caas` CLI ([The CLI](/docs/cli)) is built on it.

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.caas.sh/), 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.

The **Workspace** card on the same page shows the workspace's **Tenant ID**, which every API path includes.

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_TENANT=<tenant-id>
export CAAS_API_KEY=<api-key>

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

Every route in this guide starts with `/v1/tenants/<tenant-id>`, written `…` from here on: `GET …/sandboxes` is `GET https://api.caas.sh/v1/tenants/<tenant-id>/sandboxes`. A key works only in the workspace it was created in. With another workspace's ID, it gets `401`.

### 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 and MCP servers, their commands, snapshots, logs, events and outbound requests; workflow runs; credentials, egress rules and egress policies; the workspace's settings and audit log |
| `sandbox:write` | Create, start, stop and delete sandboxes and MCP servers; upload source archives; take, restore and delete snapshots; set the nightly snapshot, the sandbox URL and the egress policy; change the workspace's settings; change credentials and egress rules (owners only) |
| `sandbox:execute` | Run and cancel commands; read, write and delete files; create sandbox URL access tokens; pause and cancel workflow runs. Starting or resuming a run needs `sandbox:write` as well |
| `mcp:read` | List an MCP server's client tokens |
| `mcp:write` | Create 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`. Changing credentials, OAuth and GitHub apps, and egress rules needs an owner's key.

A revoked key, or a changed role, takes effect within a minute. Some requests check the key at once instead: creating MCP client tokens and sandbox URL tokens, turning on or changing a sandbox URL, changing credentials, apps, egress rules and egress policies, starting and resuming workflow runs, and adding funds or changing auto-recharge.

## Requests and responses

- Send request bodies as JSON, with `Content-Type: application/json`, except for [files](#files) and [source archives](#upload-a-source-archive). A body larger than its route takes gets `413`.
- Sandbox, snapshot, run, client token and egress rule IDs are UUIDs.
- Times are milliseconds since the Unix epoch.
- Money is in micro-USD: 1,000,000 is $1.
- A list that comes in pages has a cursor: `nextCursor`, or `cursor` in the audit log. Pass it back as `?cursor=` for the next page. It's `null` on the last page.

## Errors

An error has a JSON body with a readable `error` message. Some errors add a `code` you can branch on, and more fields:

```json
{
  "error": "The workspace balance is empty; add funds in the console before running sandboxes",
  "code": "out_of_funds",
  "billingUrl": "https://console.caas.sh/usage"
}
```

| Status | Meaning |
| --- | --- |
| `400` | The request is invalid: a malformed ID, an unknown field, or a value outside its limits. `error` names the problem. |
| `401` | No API key, or one that's unknown, revoked, expired or from another workspace |
| `402` | The workspace can't run sandboxes; see `code` |
| `403` | The key's scopes or its creator's role don't allow the request, or the workspace is suspended |
| `404` | No such route, sandbox, command, snapshot or other resource |
| `405` | The route doesn't take this method |
| `409` | The request conflicts with the resource's state, such as restoring a snapshot into a running sandbox, or reusing an idempotency key with a different body |
| `413` | The body is too large |
| `415` | A source upload that isn't `application/gzip` |
| `429` | A rate limit, the workspace's quota, or a count limit was reached |
| `503` | A temporary failure. Try again shortly. |
| `504` | The work is still in progress, such as a snapshot still being taken. Check again shortly. |

| `code` | Status | Meaning |
| --- | --- | --- |
| `payment_method_required` | `402` | No card is on file. |
| `out_of_funds` | `402` | The balance is $0 or below. |
| `spend_cap_reached` | `402` | This month's spend cap is reached. |
| `tenant_suspended` | `403` | The workspace is suspended. Suspension also revokes the workspace's API keys. |
| `quota_exceeded` | `429` | The sandbox or workflow run would pass the workspace's vCPU or memory quota. The body has `quota`, `used` and `requested`. |
| `rate_limited` | `429` | A rate limit was reached; see [Rate limits](#rate-limits). |

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. caas.sh logs everything the request did under that ID, and the audit log records it with each change. Quote it when you report a problem. When something fails on caas.sh's side, the `503` body has it too, as `requestId`.

## 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 |
| To sandbox URLs, from one client address | 1,200 a minute |
| To one sandbox URL | 6,000 a minute |

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

`GET …/sandboxes` returns the sandboxes with their status in one request, so poll it instead of reading each sandbox.

## Idempotency keys

Requests that create something you mustn't create twice need an `Idempotency-Key` header of 8 to 128 characters. 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 | Key | A repeat with a different body |
| --- | --- | --- |
| `POST …/sandboxes` | Any 8 to 128 characters, such as a UUID | `409` |
| `POST …/sandboxes/<sandbox-id>/commands` | 8 to 128 letters, digits, `-` and `_`. It becomes the command's ID. | `409` |
| `POST …/workflow-runs` | 8 to 128 letters, digits, `-` and `_` | `409` |
| `POST …/billing/top-ups` | Any 8 to 128 characters | `409` |

- A repeated sandbox create answers `200` instead of `201`. For an MCP server, it doesn't return the client tokens' secrets again; it has `"clientTokenNotReturned": true` instead.
- A key whose sandbox you've since deleted gets `409`.
- Command IDs belong to their sandbox. A command's key can be used again once the command's record is gone.

```sh
curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "kind": "agent", "source": { "type": "stdio", "command": ["node", "--version"] } }'
```

To retry this safely, save the key in a variable and send the same one each time.

## Sandboxes

### Create a sandbox

```sh
curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: build-agent-0001" \
  -d '{
    "kind": "agent",
    "name": "build-agent",
    "size": "l",
    "source": { "type": "stdio", "command": ["node", "--version"] },
    "egressHosts": ["api.github.com"]
  }'
```

The answer is `201 Created` with the new sandbox. It starts at once, and runs its command once it's running:

```json
{
  "id": "7c9e4a2b-1f3d-4b6e-8a5c-3d2f1e0b9a87",
  "tenantId": "0b6f2c1e-4d5a-4e8b-9c3f-2a7d1e6b8f40",
  "kind": "agent",
  "name": "build-agent",
  "status": "starting",
  "endpointId": null,
  "deployment": {
    "source": { "type": "stdio", "command": ["node", "--version"] },
    "size": "l",
    "egressHosts": ["api.github.com"],
    "idleStopMinutes": 15,
    "wakeOnRequest": false,
    "persistent": true
  },
  "resources": { "vcpu": 1, "memoryMib": 6144, "diskGb": 12 },
  "url": null,
  "lastSnapshot": null,
  "snapshotSchedule": { "time": "02:00", "timeZone": "UTC", "nextAt": 1791856800000 },
  "egress": {
    "policy": [{ "host": "api.github.com", "action": "allow" }, { "host": "*", "action": "deny" }],
    "credentials": []
  },
  "stopReason": null,
  "agentExit": null,
  "createdAt": 1791797400000,
  "updatedAt": 1791797400000
}
```

The body takes these fields:

| Field | Value |
| --- | --- |
| `kind` | `"agent"` for a sandbox, or `"mcp"` for an MCP server (see [MCP servers](#mcp-servers)). Required. |
| `source` | What it runs. Required. See below. |
| `size` | `"xs"`, `"s"`, `"m"`, `"l"`, `"xl"` or `"2xl"`, or a custom size such as `{ "vcpu": 2, "memoryGib": 8, "diskGb": 16 }`. Defaults to `"m"`. See [Sizes](/docs/sandboxes#sizes). |
| `name` | Up to 63 lowercase letters, digits and hyphens, unique in the workspace. Optional. |
| `egressHosts` | Up to 32 exact host names the sandbox may reach, such as `api.github.com`. |
| `persistent` | `false` to start with a fresh workspace every time. Defaults to `true`. |
| `idleStopMinutes` | 5 to 240. Defaults to 15. |
| `wakeOnRequest` | `true` to let requests start a sandbox you stopped. Defaults to `false`. |
| `expose` | `{ "port": 8080, "public": false }` turns on the sandbox's URL. |
| `snapshotTime` | The nightly snapshot's time as `"HH:MM"` in the workspace's time zone, or `null` for none. Defaults to `"02:00"` when the sandbox keeps state. |

`source` is one of:

| Source | What it runs |
| --- | --- |
| `{ "type": "stdio", "command": ["node", "task.js"] }` | A command alone: 1 to 32 arguments, run without a shell. Add `"uploadId"` to unpack an uploaded archive first. |
| `{ "type": "git", "url": "https://github.com/your-org/agent", "ref": "main", "command": ["node", "task.js"] }` | A public HTTPS Git repository, cloned into `/workspace`. `ref` is optional. |
| `{ "type": "image", "reference": "registry.caas.sh/<tenant-id>/agent@sha256:…", "command": ["node", "agent.js"] }` | Your own image, by digest, with size `"m"` or larger. An agent sandbox needs a command. |

[Choose what it runs](/docs/sandboxes#choose-what-it-runs) has the details of each source, and [Images and the registry](/docs/images) shows how to push an image.

### Upload a source archive

Upload a gzipped tar of up to 250 MiB, then pass the `uploadId` it returns in a `stdio` source. An upload is kept for seven days.

```sh
tar -czf source.tar.gz -C my-project .

curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/uploads \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/gzip" \
  --data-binary @source.tar.gz
```

The answer is `201` with `{ "uploadId": "…", "size": 18342 }`. See [An uploaded archive](/docs/sandboxes#an-uploaded-archive).

### Sandbox routes

| Route | Scope | What it does |
| --- | --- | --- |
| `GET …/sandboxes` | `sandbox:read` | Lists the workspace's sandboxes and MCP servers, newest first, up to 500: `{ "resources": [...] }`. Each has its `sandbox_id`, `kind`, `name` and `created_at`, and, once created, its `status`, as the next route returns it. |
| `GET …/sandboxes/<sandbox-id>` | `sandbox:read` | The sandbox, as in the create response, with its `usage`. An MCP server also has its `endpointUrl`, and its `gateway` figures for the last 24 hours. |
| `GET …/sandboxes/<sandbox-id>/detail` | `sandbox:read` | Everything the console's sandbox page shows, in one request: `status`, `snapshots`, `events`, `logs`, the newest outbound requests (`egress`), the first page of `logArchives`, and, with `mcp:read`, an MCP server's `clientTokens` |
| `POST …/sandboxes/<sandbox-id>/start` | `sandbox:write` | Starts a stopped sandbox |
| `POST …/sandboxes/<sandbox-id>/stop` | `sandbox:write` | Stops a running sandbox |
| `DELETE …/sandboxes/<sandbox-id>` | `sandbox:write` | Deletes the sandbox with its workspace, snapshots, logs and URL: `{ "id": "…", "status": "deleted" }` |
| `GET …/sandboxes/<sandbox-id>/events` | `sandbox:read` | Its newest events, up to 100: `{ "events": [...] }` |
| `GET …/sandboxes/<sandbox-id>/logs` | `sandbox:read` | Its recent runtime log: `{ "logs": [...] }` |
| `GET …/sandboxes/<sandbox-id>/log-archives` | `sandbox:read` | Its runtime log archives, 100 a page: `{ "archives": [...], "nextCursor": … }` |
| `GET …/sandboxes/<sandbox-id>/log-archives/<archive-id>` | `sandbox:read` | One archive, as JSONL |
| `GET …/sandboxes/<sandbox-id>/egress` | `sandbox:read` | The outbound requests it tried to make, newest first, and whether its policy allowed them: `{ "attempts": [...], "nextCursor": … }` |
| `PUT …/sandboxes/<sandbox-id>/url` | `sandbox:write` | Turns on or changes its URL: `{ "port": 8080, "public": false }` |
| `DELETE …/sandboxes/<sandbox-id>/url` | `sandbox:write` | Turns off its URL, ending every access token and session |
| `POST …/sandboxes/<sandbox-id>/url-tokens` | `sandbox:execute` | Creates an access token for a protected URL: `{ "ttlSeconds": 600 }` (60 to 86,400; 3,600 by default) returns `{ token, expiresAt, url, sessionUrl }` |

A sandbox's `status` is `starting`, `running`, `stopping`, `stopped`, `error`, `deleting` or `deleted`. Start and stop wait up to 25 seconds for the sandbox to finish starting or stopping. Start then answers with the sandbox, and stop with its `id` and `status`. If it's still under way when the wait ends, the answer is `202` with the sandbox: read it until its `status` changes. Start also takes a sandbox in `error`. A start the workspace's balance doesn't allow gets `402`.

[Sandbox URLs](/docs/sandboxes#sandbox-urls) explains ports, access tokens and sessions.

## Commands

Send a command with an idempotency key, which becomes the command's ID:

```sh
curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/commands \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: say-hello-0001" \
  -d '{ "command": ["echo", "hello"], "timeoutMs": 60000 }'
```

The answer is `202 Accepted` with the command's record. Then read it, waiting up to 25 seconds for it to end:

```sh
curl "https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/commands/say-hello-0001?waitMs=25000" \
  -H "Authorization: Bearer $CAAS_API_KEY"
```

```json
{
  "id": "say-hello-0001",
  "state": "succeeded",
  "exitCode": 0,
  "signal": null,
  "stdout": { "bytes": 6, "base64": "aGVsbG8K", "truncated": false },
  "stderr": { "bytes": 0, "base64": "", "truncated": false },
  "createdAt": 1791797462000
}
```

| Route | Scope | What it does |
| --- | --- | --- |
| `POST …/sandboxes/<sandbox-id>/commands` | `sandbox:execute` | Runs `{ "command": [...] }`, 1 to 32 arguments without a shell, or `{ "script": "…" }`, a shell script of up to 131,071 bytes run by `/bin/sh -c`. `timeoutMs` is 1,000 to 7,200,000, and 600,000 by default. |
| `GET …/sandboxes/<sandbox-id>/commands/<command-id>` | `sandbox:read` | The command's record. `?waitMs=` (up to 25,000) holds the answer until the command ends or the wait passes. |
| `GET …/sandboxes/<sandbox-id>/commands/<command-id>/stdout` | `sandbox:read` | The whole of stdout, once the command has ended. Use `/stderr` for stderr. |
| `DELETE …/sandboxes/<sandbox-id>/commands/<command-id>` | `sandbox:execute` | Cancels a running command, and returns its record |

A command's `state` is `submitting`, `running`, `succeeded`, `failed`, `timed_out`, `canceled` or `lost`. When a stream is short, its `base64` holds all of it; otherwise `base64` is `null`, and you download the stream from `/stdout` or `/stderr`. Downloaded output bills as egress.

A command sent to a sandbox that stopped while idle starts it first. An agent sandbox's first command has the ID `initial-agent-command`. Records are kept for seven days after a command ends, at most 1,000 per sandbox. See [Run commands](/docs/sandboxes#run-commands).

## Files

The file routes read, write and delete one file at a time under `/workspace` or `/tmp`, other than `/workspace/.caas`, which belongs to caas.sh. They need `sandbox:execute`, reads included, because they reach any file a command could.

| Route | What it does |
| --- | --- |
| `PUT …/sandboxes/<sandbox-id>/files?path=<path>` | Writes the body, up to 100 MiB, as the whole file, creating its directories: `{ "bytes": … }` |
| `GET …/sandboxes/<sandbox-id>/files?path=<path>` | Returns the file's bytes. Add `&tail=4096` for only its last 4,096 bytes. |
| `DELETE …/sandboxes/<sandbox-id>/files?path=<path>` | Deletes the file: `{ "deleted": true }`, or `false` when there was none |

```sh
curl -X PUT "https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/files?path=/workspace/config.json" \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  --data-binary @config.json
```

A transfer starts a sandbox that stopped while idle. While the sandbox is still starting, you get `503`; while a transfer is still running, `504`. Try again shortly in both cases. The bytes you read bill as egress. See [Read and write files](/docs/sandboxes#read-and-write-files).

## Snapshots

| Route | Scope | What it does |
| --- | --- | --- |
| `GET …/sandboxes/<sandbox-id>/snapshots` | `sandbox:read` | `{ snapshots, storedBytes, schedule }`, newest first. Each snapshot has its `id`, `name`, `kind` (`manual`, `scheduled` or `automatic`), `scheduleTime`, `createdAt`, `size`, and whether it's the `current` recovery point. |
| `POST …/sandboxes/<sandbox-id>/snapshot` | `sandbox:write` | Takes a snapshot of a running sandbox, named with an optional `{ "name": "before-upgrade" }`: `{ "snapshot": { … } }` |
| `DELETE …/sandboxes/<sandbox-id>/snapshots/<snapshot-id>` | `sandbox:write` | Deletes a snapshot: `{ "id": "…", "deleted": true }` |
| `POST …/sandboxes/<sandbox-id>/restore` | `sandbox:write` | Starts a stopped or failed sandbox from `{ "snapshotId": "…" }`, and answers with the sandbox and its `restoredSnapshotId` |
| `PUT …/sandboxes/<sandbox-id>/snapshot-schedule` | `sandbox:write` | Moves the nightly snapshot, `{ "time": "03:30", "timeZone": "Europe/Berlin" }`, or turns it off, `{ "time": null }`: `{ schedule }` |

```sh
curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/snapshot \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "before-upgrade" }'
```

Taking a snapshot of a large workspace can outlast the request. You then get `504`, and the snapshot is listed once it's done. A snapshot of a sandbox that isn't running gets `409`, and so does deleting the current recovery point of a sandbox that keeps state. See [Snapshots](/docs/sandboxes#snapshots).

## MCP servers

### Deploy a server

An MCP server is a sandbox with `"kind": "mcp"`, created with `POST …/sandboxes`. Its `command` starts the server, which speaks MCP on stdio; a server on your own image can serve HTTP instead, with `"transport": "http"` and its `"port"` in the source. `clientTokens` declares up to 20 client tokens, each with a `label`, and optionally `allowedTools` and `expiresAt`:

```sh
curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: search-mcp-0001" \
  -d '{
    "kind": "mcp",
    "name": "search-mcp",
    "source": { "type": "stdio", "command": ["npx", "-y", "search-mcp"] },
    "egressHosts": ["registry.npmjs.org", "api.search.example.com"],
    "clientTokens": [
      { "label": "laptop-claude" },
      { "label": "ci-pipeline", "allowedTools": ["search.query"] }
    ]
  }'
```

The answer has the server's `endpointUrl`, `https://mcp.caa.sh/<endpoint-id>`, and `clientTokens`: each token's `secret` and its `token` record. Without `clientTokens`, the server gets one token, `Initial client`, whose secret is in `clientToken`. Every secret is returned only once, so save it.

### Client tokens

| Route | Scope | What it does |
| --- | --- | --- |
| `GET …/sandboxes/<sandbox-id>/client-tokens` | `mcp:read` | `{ "tokens": [...] }`: each token's `id`, `label`, `createdAt`, `expiresAt`, `revokedAt`, `lastUsedAt`, `allowedTools`, `requestCount` and `errorCount`, never its secret |
| `POST …/sandboxes/<sandbox-id>/client-tokens` | `mcp:write` | Creates a token, `{ "label": "…", "allowedTools": [...], "expiresAt": … }`, and returns `201` with its `secret` and `token` |
| `POST …/sandboxes/<sandbox-id>/client-tokens/<token-id>/revoke` | `mcp:write` | Revokes a token at once: `{ "token": { … } }` |

```sh
curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/client-tokens \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "label": "ci-pipeline", "allowedTools": ["search.query"] }'
```

- `label` is 1 to 80 characters.
- `allowedTools` lists up to 32 tool names the token may call. Leave it out to allow every tool; an empty list allows none.
- `expiresAt` is a time more than a minute and at most a year ahead. Leave it out for a token that never expires.
- A server can have 20 tokens in force at once. A new token beyond that gets `429`.

### Call the endpoint

MCP clients call the server at its `endpointUrl` with a client token, not an API key:

```sh
curl https://mcp.caa.sh/<endpoint-id> \
  -H "Authorization: Bearer $CAAS_MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
```

[Connect a client](/docs/mcp#connect-a-client) covers client configuration and the gateway's limits, and [Tool grants](/docs/mcp#tool-grants) what a limited token can do.

## Workflow runs

### Start a run

Send the workflow file's text as `file`, with its `inputs`. This example uses `jq` to build the body from `build-and-test.yaml`, the file in [Workflows](/docs/workflows):

```sh
jq -n --rawfile file build-and-test.yaml '{
  file: $file,
  inputs: { branch: "release" },
  images: { "registry.caas.sh/ci:node24": "registry.caas.sh/<tenant-id>/ci@sha256:<digest>" }
}' | curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/workflow-runs \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @-
```

| Field | Value |
| --- | --- |
| `file` | The workflow file, YAML or JSON, up to 64 KiB. Required. |
| `inputs` | A value, as text, for each input the file declares. An input you leave out keeps its default; one without a default is required. |
| `images` | Maps each image as the file writes it to its digest in your workspace's registry, `registry.caas.sh/<tenant-id>/NAME@sha256:…`. The API doesn't resolve tags: an image the file writes in exactly that form needs no entry, and any other (a tag, no tag, or a digest without your tenant ID) does. |

The answer is `201` with the run:

```json
{
  "id": "5d2b7e1a-9c4f-4a3e-b8d6-1f0e2c3a4b5d",
  "name": "build-and-test",
  "number": 12,
  "status": "queued",
  "resumes": 0,
  "progress": { "total": 3, "done": 0, "states": ["pending", "pending", "pending"], "current": null, "failed": null },
  "createdAt": 1791797400000,
  "startedAt": null,
  "endedAt": null,
  "costMicroUsd": 0,
  "resumeFrom": null,
  "error": null
}
```

A file with an error gets `400`, naming the line or field. So does a run whose steps name a secret that isn't connected and added by a workspace egress rule. The workspace may have 10 runs queued, running or paused at once; another gets `429`.

### Run routes

| Route | Scope | What it does |
| --- | --- | --- |
| `POST …/workflow-runs` | `sandbox:write` and `sandbox:execute` | Starts a run |
| `GET …/workflow-runs` | `sandbox:read` | The newest 100 runs: `{ "runs": [...] }` |
| `GET …/workflow-runs/<run-id>` | `sandbox:read` | One run with its `inputs`, `steps`, `checkpoints`, the files the newest checkpoint carried (`carried`), and its `cost` |
| `POST …/workflow-runs/<run-id>/pause` | `sandbox:execute` | Pauses a running run: the steps already running finish, and no more start |
| `POST …/workflow-runs/<run-id>/cancel` | `sandbox:execute` | Cancels a queued, running or paused run |
| `POST …/workflow-runs/<run-id>/resume` | `sandbox:write` and `sandbox:execute` | Lifts a pause, or runs an ended run again from the steps that didn't finish. With `{ "checkpoint": "cp-2" }`, replays it from that checkpoint with the same inputs. |

Pause, cancel and resume answer with the run. A run's `status` is `queued`, `running`, `paused`, `failed`, `succeeded` or `cancelled`. A succeeded run can only be replayed from a checkpoint, and a run that ended more than seven days ago can't be resumed (`409`). See [Pause, resume and cancel](/docs/workflows#pause-resume-and-cancel).

## Credentials and outbound access

A credential holds a key or token that caas.sh adds to sandboxes' outbound requests, so the key never enters a sandbox. Egress rules say which requests get which credential, and each sandbox's egress policy says which hosts it may reach. [Credentials and outbound access](/docs/credentials) explains how they work together.

Reading them needs `sandbox:read`. Changing credentials, apps and egress rules needs an owner's key with `sandbox:write`; changing a sandbox's egress policy needs `sandbox:write`.

### Credential routes

| Route | What it does |
| --- | --- |
| `GET …/credentials` | `{ "credentials": [...] }`: each one's `name`, `method`, `provider`, `status`, `scopes` and how many `rules` name it, never its value |
| `GET …/credentials/<name>` | One credential: `{ "credential": { … } }` |
| `POST …/credentials` | Connects a credential: `{ "credential": { "name": "…", "method": "…" }, "secret": "…" }` |
| `POST …/credentials/<name>/reconnect` | Replaces an API key's value, `{ "secret": "…" }`, or starts a new sign-in for any other method. An `oauth` credential may ask for new `{ "scopes": [...] }`. |
| `DELETE …/credentials/<name>` | Removes the credential and the rules that name it |
| `GET …/credential-providers` | The OAuth and GitHub apps credentials connect through: `{ "providers": [...] }` |
| `POST …/credential-providers` | Adds or replaces an app: `{ "provider": { "id": "…", "type": "oauth2", "config": { … } }, "secret": "…" }` |
| `DELETE …/credential-providers/<app-id>` | Removes an app that no credential uses |

A credential's `name` is capitals, digits and `_`, up to 64 characters, not starting with `CAAS_`. Its `method` is one of:

- `api_key`: a key you send as `secret`. It's `ready` at once.
- `oauth`: a sign-in through an OAuth app you added, named in `provider`, optionally asking for `scopes`.
- `github_app`: an installation of a GitHub App you added, named in `provider`.
- `github_user`: a GitHub sign-in.
- `codex`: a ChatGPT account, through a device code.

For every method but `api_key`, the answer's `credential.action` has the next step: a page to sign in at (`"kind": "redirect"`), or a device code to enter at a page (`"kind": "device"`). A sign-in finishes in the console, signed in as the key's creator.

For example, to connect an Anthropic key and add it to requests to `api.anthropic.com`:

```sh
jq -n --rawfile key anthropic-key.txt \
  '{ credential: { name: "ANTHROPIC_API_KEY", method: "api_key" }, secret: ($key | rtrimstr("\n")) }' |
  curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/credentials \
    -H "Authorization: Bearer $CAAS_API_KEY" \
    -H "Content-Type: application/json" \
    --data-binary @-

curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/egress-rules \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rule": { "preset": "anthropic", "credential": "ANTHROPIC_API_KEY" } }'
```

### Egress rules

| Route | What it does |
| --- | --- |
| `GET …/egress-rules` | `{ "rules": [...] }` |
| `POST …/egress-rules` | Adds a rule, or a preset's rules: `201` with `{ "rules": [...] }` |
| `DELETE …/egress-rules/<rule-id>` | Removes a rule: `{ "id": "…", "deleted": true }` |

A rule names a credential, the host it goes to, and how it's sent:

```json
{ "rule": { "credential": "SEARCH_API_KEY", "host": "api.search.example.com", "header": "authorization", "scheme": "bearer" } }
```

- `host` is an exact host, or `*.example.com` for example.com and every name under it.
- `header` is the request header the credential goes in, such as `authorization`.
- `scheme` is `bearer` (`Bearer <value>`), `basic` (with a `username`) or `raw` (the value alone).
- `sandboxId` limits the rule to one sandbox. Without it, the rule applies to every sandbox in the workspace.

Or send `{ "rule": { "preset": "github", "credential": "GITHUB_TOKEN" } }`, optionally with `sandboxId`, for a preset's rules:

| Preset | Adds the credential to |
| --- | --- |
| `github` | `api.github.com` as a bearer token, and `github.com` for Git over HTTPS |
| `anthropic` | `api.anthropic.com`, in `x-api-key` |
| `openai` | `api.openai.com`, as a bearer token |
| `google` | `generativelanguage.googleapis.com`, in `x-goog-api-key` |

Up to 16 rules apply to one sandbox, the workspace's and its own together. A running sandbox takes changed rules when it next starts.

### Egress policy

| Route | Scope | What it does |
| --- | --- | --- |
| `GET …/sandboxes/<sandbox-id>/egress-policy` | `sandbox:read` | `{ "policy": [...] }` |
| `PUT …/sandboxes/<sandbox-id>/egress-policy` | `sandbox:write` | Replaces the policy with `{ "rules": [...] }`, and applies it to a running sandbox at once: `{ "policy": [...] }` |

```sh
curl -X PUT https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/egress-policy \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rules": [
    { "host": "github.com", "action": "allow" },
    { "host": "*.example.com", "action": "deny" },
    { "host": "pypi.org", "action": "allow" }
  ] }'
```

The first rule that matches a host decides. Up to 64 rules come before the closing `* deny`, which the policy always ends with: you may leave it out, and `* allow` is refused. In a policy, `*.example.com` matches every name under example.com, but not example.com itself.

## Billing

Billing routes need a key with **Billing access**, which only an owner can create.

| Route | Scope | What it does |
| --- | --- | --- |
| `GET …/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 …/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 …/billing/auto-recharge` | `billing:read` | `{ enabled, minimumMicroUsd, targetMicroUsd, active, failure }` |
| `PUT …/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 …/billing/top-ups` | `billing:write` | Adds funds, `{ "amountMicroUsd": 25000000 }` ($5 to $2,000, in whole cents), with an idempotency key |

```sh
curl "https://api.caas.sh/v1/tenants/$CAAS_TENANT/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 …/settings` | `sandbox:read` | `{ "timeZone": "UTC" }` |
| `PATCH …/settings` | `sandbox:write` | Sets the time zone new sandboxes take their nightly snapshot in, as an IANA name: `{ "timeZone": "Europe/Berlin" }` |
| `GET …/audit` | `sandbox:read` | The audit log, newest first: `{ records, cursor, retentionDays }` |

`GET …/audit` takes these filters, each at most once:

| Parameter | Value |
| --- | --- |
| `action` | An action, such as `sandbox.stop`, or a group, such as `sandbox` or `command` |
| `actor` | A user's or API key's ID, `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 `cursor` |

Each record has its time (`at`), `actor`, `action`, `target`, `outcome`, HTTP `status`, `requestId` and a short `detail`. 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.
