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) maps its commands onto it one to one, and the TypeScript SDK (The TypeScript 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
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.
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 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. A body larger than its route takes gets413.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 itsnextCursorbeside it: pass it back as?cursor=for the next page. It'snullon 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'connectionsand whether anaccountwent with it; a file answers{ "path": "…", "deleted": true }. Revoking a client token answers the token.
Errors
Every error has the same JSON body:
{ "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 |
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.
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 for every field and what it does.
Create a sandbox
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:
{
"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 |
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 |
GET …/metrics | sandbox:read | Its CPU, memory and disk over time, as its provider measured them; see Metrics |
GET, PUT, DELETE …/files/<path> | sandbox:execute | Its files; see 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 |
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
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:
{ "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 |
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 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.
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). 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.
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.
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) |
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, and 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.