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) 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
In the console, open Settings.
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.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:
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 and source archives. A body larger than its route takes gets413.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, orcursorin the audit log. Pass it back as?cursor=for the next page. It'snullon 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:
{
"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. |
Every 402 has a billingUrl: the console page where an owner adds funds or a card. See Billing and usage.
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
200instead of201. For an MCP server, it doesn't return the client tokens' secrets again; it has"clientTokenNotReturned": trueinstead.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.
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
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:
{
"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). 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. |
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 has the details of each source, and Images and the registry 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.
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.gzThe answer is 201 with { "uploadId": "…", "size": 18342 }. See 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 explains ports, access tokens and sessions.
Commands
Send a command with an idempotency key, which becomes the command's ID:
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:
curl "https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/commands/say-hello-0001?waitMs=25000" \
-H "Authorization: Bearer $CAAS_API_KEY"{
"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.
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 |
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.jsonA 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.
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 } |
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.
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:
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": { … } } |
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"] }'labelis 1 to 80 characters.allowedToolslists up to 32 tool names the token may call. Leave it out to allow every tool; an empty list allows none.expiresAtis 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:
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 covers client configuration and the gateway's limits, and 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:
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:
{
"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.
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 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 assecret. It'sreadyat once.oauth: a sign-in through an OAuth app you added, named inprovider, optionally asking forscopes.github_app: an installation of a GitHub App you added, named inprovider.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:
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:
{ "rule": { "credential": "SEARCH_API_KEY", "host": "api.search.example.com", "header": "authorization", "scheme": "bearer" } }hostis an exact host, or*.example.comfor example.com and every name under it.headeris the request header the credential goes in, such asauthorization.schemeisbearer(Bearer <value>),basic(with ausername) orraw(the value alone).sandboxIdlimits 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": [...] } |
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 |
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, and pricing 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.