REST API

REST API reference

The sandbox REST API lives at https://api.orkestr.eu/v1. Use it directly from any language, or pick the Python or JS SDK and skip the wire format entirely.

Conventions
All requests and responses use JSON with snake_case field names. Errors return a FastAPI-style {"detail": "..."} body plus an optional code field for machine-readable dispatch.

Base URL

bash
https://api.orkestr.eu/v1

Authentication

Every request must carry a Bearer token in the Authorization header. Mint tokens in the orkestr console with the sandboxes:read and sandboxes:write scopes. Tokens that lack the required scope get 403 Forbidden.

bash
curl https://api.orkestr.eu/v1/sandboxes \
  -H "Authorization: Bearer $ORKESTR_API_KEY"

Endpoints

Endpoints across lifecycle, keep-alive, exec, the code interpreter, files, metrics, activity events, lifecycle webhooks, port exposure, pause / resume, and custom templates:

  • POST /v1/sandboxes - create a sandbox
  • GET /v1/sandboxes - list yours; filter with status and repeated metadata[key]=value (AND, exact match), cursor-paged via next_cursor/cursor
  • GET /v1/sandboxes/limits - sizes and caps your plan allows
  • GET /v1/sandboxes/{id} - get one
  • GET /v1/sandboxes/{id}/metrics - live CPU + memory
  • GET /v1/sandboxes/events - account-wide activity feed across all your sandboxes, newest first; filter with sandbox_id / repeated event_type, page with before, poll with since
  • POST /v1/sandboxes/webhooks - register a lifecycle webhook (signed POSTs instead of polling)
  • GET /v1/sandboxes/webhooks - list yours
  • POST /v1/sandboxes/webhooks/{id}/enable - re-enable one that was disabled after repeated failures
  • DELETE /v1/sandboxes/webhooks/{id} - delete
  • GET /v1/sandboxes/{id}/host?port=N - public URL for a port
  • DELETE /v1/sandboxes/{id} - terminate
  • POST /v1/sandboxes/{id}/exec - run a command
  • POST /v1/sandboxes/{id}/code - run code in a persistent kernel (code-interpreter template)
  • POST /v1/sandboxes/{id}/code/contexts - create an isolated kernel context
  • GET /v1/sandboxes/{id}/code/contexts - list kernel contexts
  • POST /v1/sandboxes/{id}/code/contexts/{ctx}/restart - restart a context (fresh namespace, same id)
  • DELETE /v1/sandboxes/{id}/code/contexts/{ctx} - shut down and remove a context
  • POST /v1/sandboxes/{id}/commands - start a background command (returns a handle)
  • GET /v1/sandboxes/{id}/commands[/{cmd}] - list / poll background commands
  • DELETE /v1/sandboxes/{id}/commands/{cmd} - kill (SIGTERM, then SIGKILL)
  • GET /v1/sandboxes/{id}/commands/{cmd}/logs - buffered output; follow=true for the live SSE stream
  • POST /v1/sandboxes/{id}/files - write file
  • GET /v1/sandboxes/{id}/files?path=... - read file or list dir
  • DELETE /v1/sandboxes/{id}/files?path=... - delete file
  • POST /v1/sandboxes/{id}/files/mkdir - create a directory
  • POST /v1/sandboxes/{id}/files/move - move / rename
  • GET /v1/sandboxes/{id}/files/stat?path=... - stat / exists (exists=false, not 404)
  • POST /v1/sandboxes/{id}/files/batch - write many files in one call
  • POST /v1/sandboxes/{id}/files/presign-download - mint a pre-signed download URL
  • POST /v1/sandboxes/{id}/files/presign-upload - mint a pre-signed upload URL
  • POST /v1/sandboxes/{id}/timeout - extend the lifetime (keep-alive)
  • POST /v1/sandboxes/{id}/pause - snapshot + suspend
  • POST /v1/sandboxes/{id}/resume - restore
  • POST /v1/templates - build a custom template
  • GET /v1/templates - list yours
  • GET /v1/templates/{id} - get one (poll build status)
  • DELETE /v1/templates/{id} - delete a template + its image
  • PUT /v1/templates/{id}/tags/{tag} - assign/move a tag to a build
  • DELETE /v1/templates/{id}/tags/{tag} - remove a tag
  • POST /v1/sandboxes/volumes - create a persistent volume
  • GET /v1/sandboxes/volumes - list yours
  • POST /v1/sandboxes/volumes/{id}/move - move a detached volume to another region
  • DELETE /v1/sandboxes/volumes/{id} - delete a volume + its data

Create a sandbox

POST /v1/sandboxes requires the sandboxes:write scope.

Request

import httpx, os

response = httpx.post(
    "https://api.orkestr.eu/v1/sandboxes",
    headers={"Authorization": f"Bearer {os.environ['ORKESTR_API_KEY']}"},
    json={
        "template": "python-3.12",
        "size": "small",
        "network": "off",
        "timeout_seconds": 600,
        "env": {"OPENAI_API_KEY": "sk-..."},
        "metadata": {"agent_run": "r_42"},
    },
)
sandbox = response.json()
ParameterTypeDescription
template requiredstringA built-in - python-3.12, python-3.12-bare, node-22, debian-12, code-interpreter - or a custom template id (tmpl_...) from POST /v1/templates.
sizestringFixed size: "small" (1 vCPU / 1 GB), "medium" (2 vCPU / 4 GB) or "large" (4 vCPU / 8 GB). Default "small". Tier-capped (free: small; payg and enterprise: small, medium, large).
networkstringoff, restricted, or open.
timeout_secondsintegerAuto-terminate after this many seconds (10 - 86400). Capped by your tier's max_timeout_seconds from GET /v1/sandboxes/limits (free: 1800, i.e. 30 min; with a card on file: 86400, i.e. 24 h). Default 600.
on_timeoutstringWhat happens at the deadline: kill (default) terminates the sandbox; pause snapshots it so a later /resume continues where it left off. On a paid tier the pause always succeeds; on the free tier it falls back to terminate if you are already at your snapshot cap.
auto_resumebooleanWhen true, an exec or file call on a paused sandbox resumes it automatically first, then runs - instead of returning 409 - and an HTTP request to one of its public preview URLs wakes it the same way. Pairs with on_timeout=pause: park at the deadline, wake on next use. The implicit resume passes the same admission gates as /resume. Default false.
preview_authbooleanWhen true, this sandbox's preview URLs require an access token: the response carries preview_auth_token, and a preview request must present it (header X-Orkestr-Preview-Token, cookie ork_preview_token, or ?ork_preview_token=) or gets a 401. Default false keeps previews public.
envobjectEnvironment variables exposed to processes.
metadataobjectCaller-defined tags echoed back on every response. Max 16 keys.
regionstringRegion code, e.g. fsn1. The current list is regions in GET /v1/sandboxes/limits. Omit for control-plane choice.
allow_domainsstring[]For network=restricted only (paid plans): custom egress allowlist that replaces the default set for this sandbox - HTTPS-only, proxy-mediated. Bare hostnames; keep any package registries you need. GET /v1/sandboxes/limits returns the default set. Ignored for off/open.
volumestringAttach a persistent volume by name (or vol_ id), mounted at /persist. Survives terminate and returns on a later sandbox that names the same volume. Created on first use (default 10 GB). Backs one running sandbox at a time (else 409 volume_in_use) and pins the sandbox to the volume's host. See the volume endpoints above.

Response

json
{
  "id": "sbx_01HXYZ...",
  "status": "running",
  "template": "python-3.12",
  "network": "off",
  "cpu": 1.0,
  "memory_mb": 1024,
  "region": "fsn1",
  "created_at": "2026-05-19T14:22:11Z",
  "expires_at": "2026-05-19T14:32:11Z",
  "on_timeout": "kill",
  "auto_resume": false,
  "endpoints": {
    "exec": "/v1/sandboxes/sbx_01HXYZ.../exec",
    "files": "/v1/sandboxes/sbx_01HXYZ.../files",
    "pause": "/v1/sandboxes/sbx_01HXYZ.../pause",
    "resume": "/v1/sandboxes/sbx_01HXYZ.../resume"
  },
  "metadata": {"agent_run": "r_42"}
}

Run a command

POST /v1/sandboxes/{id}/exec requires sandboxes:write. Set stream to false for a buffered response, true for an SSE stream.

Request

httpx.post(
    "https://api.orkestr.eu/v1/sandboxes/sbx_01HXYZ.../exec",
    headers={"Authorization": f"Bearer {os.environ['ORKESTR_API_KEY']}"},
    json={
        "command": "python -c 'print(2+2)'",
        "cwd": "/workspace",
        "timeout_seconds": 60,
        "stream": False,
    },
).json()

Buffered response (stream=false)

json
{
  "stdout": "4\n",
  "stderr": "",
  "exit_code": 0,
  "duration_ms": 18
}

Streaming response (stream=true)

Content type is text/event-stream. Each frame is a data: line containing a JSON object with a kind field. data on chunk frames is base64-encoded.

bash
data: {"kind":"exec_started","pid":42}

data: {"kind":"exec_chunk","stream":"stdout","data":"NA=="}

data: {"kind":"exec_chunk","stream":"stderr","data":"d2Fybg=="}

data: {"kind":"exec_exited","code":0,"duration_ms":23}

Frame kinds:

ParameterTypeDescription
exec_startedeventSent once at the start. Carries the in-VM pid.
exec_chunkeventOutput burst. Has stream (stdout / stderr) and base64-encoded data.
exec_exitedeventFinal frame. Carries code and duration_ms.
erroreventError frame instead of exec_exited. Has code (e.g. exec_timeout, killed_by_signal) and message.

Run code in a persistent kernel

POST /v1/sandboxes/{id}/code requires sandboxes:write and the code-interpreter template (other templates get 409 with code: "interpreter_unavailable"). Unlike exec, calls share a persistent interpreter: variables carry over between requests, and results come back rich. Contexts are managed under /code/contexts. Full guide: Code interpreter.

Request

json
{
  "code": "df.describe()",
  "language": "python",
  "context_id": null,
  "timeout_seconds": 60
}

Streaming (stream=true)

With stream: true the response is Server-Sent Events: one stream event per output burst, one result event per rich result as the kernel produces it, error events, then a terminal done event with the summary fields - long cells report progress instead of going quiet.

Response

A raising cell is a normal 200: the exception arrives structured in error and kernel state up to the raise is kept. Each results item carries every representation the kernel emitted; type names the richest (text, html, image_png, json or chart).

json
{
  "results": [
    {
      "type": "html",
      "text": "           sales\ncount  30.000000\nmean   21.750000...",
      "html": "<table>...</table>",
      "png": null,
      "json": null,
      "chart": null
    }
  ],
  "stdout": "",
  "stderr": "",
  "error": null,
  "execution_count": 2,
  "context_id": "default",
  "duration_ms": 64,
  "interrupted": false,
  "restarted": false,
  "truncated": []
}

Files

The file API lives under /v1/sandboxes/{id}/files: single-file read / write / delete, directory operations, a batch write, and pre-signed URLs. All file content is base64 over the wire so binary round-trips cleanly. Reads and writes are capped at 16 MiB per one-shot request - for larger files, mint a pre-signed URL and move the bytes directly.

Write a file

POST /v1/sandboxes/{id}/files requires sandboxes:write. Writes go to the file API's writable roots: /workspace, /tmp, or /persist (when a volume is attached) - other paths return not_permitted. Shell commands via exec are not restricted this way.

import base64
httpx.post(
    f"{BASE}/sandboxes/{sandbox_id}/files",
    headers={"Authorization": f"Bearer {token}"},
    json={
        "path": "/workspace/main.py",
        "content": base64.b64encode(b"print(2+1)").decode(),
        "mode": 0o644,
    },
)

Read a file or list a directory

GET /v1/sandboxes/{id}/files?path=... requires sandboxes:read. Returns one shape for both cases - is_dir tells you which fields are populated.

File response

json
{
  "path": "/workspace/main.py",
  "is_dir": false,
  "content": "cHJpbnQoMisxKQ==",
  "size": 10
}

Directory response

json
{
  "path": "/workspace",
  "is_dir": true,
  "entries": [
    {
      "name": "main.py",
      "is_dir": false,
      "size": 10,
      "mode": 420,
      "modified_at": "2026-05-19T14:23:00Z"
    }
  ]
}

Delete a file

DELETE /v1/sandboxes/{id}/files?path=... requires sandboxes:write. Returns 204 No Content on success. Directories are rejected; terminate the sandbox to drop everything.

Write many files

POST /v1/sandboxes/{id}/files/batch requires sandboxes:write. Up to 64 files in one request - an agent scaffolding a project without N round-trips. Each entry takes path, base64 content, and an optional mode (default 0644). Each file rides under the one-shot 16 MiB cap, and the whole request under 48 MiB.

import base64
files = {
    "/workspace/src/app.py": b"print('hi')",
    "/workspace/README.md": b"# my app",
    "/etc/motd": b"oops",
}
httpx.post(
    f"{BASE}/sandboxes/{sandbox_id}/files/batch",
    headers={"Authorization": f"Bearer {token}"},
    json={
        "files": [
            {"path": p, "content": base64.b64encode(c).decode()}
            for p, c in files.items()
        ]
    },
)

Batch response

Files are written in order, and each reports in its own result row - a failed file carries a {"code", "message"} object in error and never aborts the rest. Check every row, not just the status code.

json
{
  "results": [
    {"path": "/workspace/src/app.py", "bytes_written": 11, "error": null},
    {"path": "/workspace/README.md", "bytes_written": 8, "error": null},
    {
      "path": "/etc/motd",
      "bytes_written": null,
      "error": {"code": "not_permitted", "message": "Path is outside the writable roots."}
    }
  ]
}

Pre-signed upload & download URLs

For files too big for the one-shot 16 MiB cap. Mint an expiring URL with POST /v1/sandboxes/{id}/files/presign-download (sandboxes:read) or .../files/presign-upload (sandboxes:write). Body: path plus an optional expires_in (60 - 3600 seconds, default 600).

httpx.post(
    f"{BASE}/sandboxes/{sandbox_id}/files/presign-download",
    headers={"Authorization": f"Bearer {token}"},
    json={"path": "/workspace/out/report.pdf", "expires_in": 600},
).json()

Response

json
{
  "url": "https://files-01hxyz....sbx.orkestr.run/workspace/out/report.pdf?ork_exp=1747666500&ork_sig=8f1c...",
  "method": "GET",
  "path": "/workspace/out/report.pdf",
  "expires_at": "2026-05-19T14:35:00Z"
}

Use the URL with plain HTTP - no auth header, no base64, no 16 MiB cap. GET streams the file down (served as an attachment). PUT uploads a raw body: Content-Length is required (chunked transfer-encoding is not supported), the file lands with mode 0644 under the writable roots, and uploads are capped at 512 MiB.

bash
# Download: plain GET streams the file (served as an attachment)
curl -o report.pdf "$URL"

# Upload: raw PUT body; curl -T sets the required Content-Length
curl -T dataset.csv "$UPLOAD_URL"
Reusable, bound, and pause-aware
A URL is reusable until expires_at, like an S3 pre-signed URL, and is bound to one method + path + sandbox - a download URL can never be replayed as an upload. Transfers ride the sandbox's host, not the VM's network, so they work for network=off sandboxes; a paused sandbox created with auto_resume=true wakes on first use of the URL. Minting on a terminated sandbox returns 409.

Extend the lifetime (keep-alive)

POST /v1/sandboxes/{id}/timeout requires sandboxes:write. Body: {"timeout_seconds": 3600}. It resets the auto-termination clock to run that many seconds from now - the countdown restarts, it is not added to the time remaining - capped by your tier's max_timeout_seconds. Returns the sandbox with the new expires_at. Only a running sandbox has a wall-clock lifetime, so a paused or terminated one returns 409; a request above the tier cap returns 400 with code: timeout_too_long.

Pause and resume

POST /v1/sandboxes/{id}/pause snapshots the sandbox and stops the compute meter. Works in every network mode - a networked sandbox's IP is held across the pause. Returns the sandbox id and the server-side snapshot id; pass the sandbox id back to /resume when you want to come back. paused_expires_at is when the snapshot will be reclaimed if never resumed (free: 7 days, with a card on file: 30 days, enterprise: null - never); each resume restarts that clock.

Pause response

json
{
  "sandbox_id": "sbx_01HXYZ...",
  "snapshot_id": "snap_...",
  "status": "paused",
  "snapshot_size_mb": 1024,
  "created_at": "2026-05-19T14:30:00Z",
  "paused_expires_at": "2026-05-26T14:30:00Z"
}

POST /v1/sandboxes/{id}/resume restores from the most recent snapshot for that sandbox. Returns the same shape as create. May be served from a different host than the original; restore latency depends on snapshot size. The snapshot is consumed by the resume - pause again to take a new one - and the sandbox's lifetime clock restarts from the resume.

Live metrics

GET /v1/sandboxes/{id}/metrics requires sandboxes:read. Returns the latest CPU and memory reading plus a rolling ~60s window of one-second samples (a sparkline in one call) and the sandbox's lifetime totals. cpu.usage_percent is normalised to allocated cores, so a one-core sandbox fully pegged reads 100. memory.usage_bytes is the working set (it excludes reclaimable file cache), so it tracks pressure that can actually run a sandbox out of memory.

Telemetry, not a state change
This endpoint never returns a 409. A paused or terminated sandbox responds 200 with null usage_* fields and an empty samples window - read sandbox_status to tell why - lifetime stays populated. Pass ?since={unix_seconds} to fetch only samples newer than your last poll, and poll no faster than sample_interval_seconds - going faster returns no new data.

Response

json
{
  "sandbox_id": "sbx_01HXYZ...",
  "sandbox_status": "running",
  "as_of": "2026-05-19T14:25:00+00:00",
  "as_of_unix": 1747665900,
  "sample_interval_seconds": 1,
  "window_seconds": 60,
  "cpu": { "cores": 1.0, "usage_cores": 0.94, "usage_percent": 94.0 },
  "memory": { "limit_bytes": 1073741824, "usage_bytes": 188743680, "usage_percent": 17.6 },
  "lifetime": { "cpu_seconds": 12.84, "gb_seconds": 5.63 },
  "samples": [
    { "t": 1747665899, "cpu_percent": 92.4, "mem_bytes": 187000000 },
    { "t": 1747665900, "cpu_percent": 94.0, "mem_bytes": 188743680 }
  ]
}

Expose a port

GET /v1/sandboxes/{id}/host?port={port} requires sandboxes:read. Returns the public URL for an HTTP port a process in the sandbox is serving. host has the form <port>-<id>.sbx.orkestr.run, where <id> is the sandbox id with the sbx_ prefix dropped and lowercased. url is host with an https:// scheme.

json
{
  "port": 3000,
  "host": "3000-01hxyz....sbx.orkestr.run",
  "url": "https://3000-01hxyz....sbx.orkestr.run"
}
The URL is public, and needs a networked, paid sandbox
The URL carries no auth - its only capability is the unguessable sandbox id in the hostname - so treat it like a secret link. It is stable for the sandbox's lifetime but serves traffic only while the sandbox is running. The endpoint requires a card on file and a sandbox created with network=restricted or open; it returns 409 Conflict for an off sandbox or a tier that cannot expose ports. WebSockets ride through (dev server HMR works). The GET /v1/sandboxes and GET /v1/sandboxes/{id} responses also carry preview_host_base - the port-less <id>.sbx.orkestr.run suffix, or null when the sandbox cannot expose a port.

Lifecycle webhooks

The push side of lifecycle observability: register an endpoint and orkestr POSTs a signed JSON payload whenever a lifecycle event happens, instead of you polling GET /v1/sandboxes/events. Registration requires sandboxes:write; up to 10 webhooks per account.

Events

  • sandbox.created, sandbox.paused, sandbox.resumed, sandbox.terminated - explicit lifecycle transitions (yours via the API, or platform-initiated - the payload's detail says which)
  • sandbox.expired - the sandbox aged out on its own: the timeout hit (whether on_timeout paused or terminated it) or a paused snapshot reached its retention limit. Distinct from sandbox.terminated so you can tell "ran out of time" from "was stopped".
  • template.build_finished - a custom template build resolved; data.status is ready or failed (no need to poll GET /v1/templates/{id})
  • volume.moved - a persistent volume finished moving to another region

Register

event_types filters what gets delivered; omit it for everything. The URL must be a publicly reachable http(s) endpoint - URLs that resolve to private or internal addresses are rejected with 400.

import httpx, os

response = httpx.post(
    "https://api.orkestr.eu/v1/sandboxes/webhooks",
    headers={"Authorization": f"Bearer {os.environ['ORKESTR_API_KEY']}"},
    json={
        "url": "https://example.com/hooks/orkestr",
        "event_types": ["sandbox.paused", "sandbox.expired"],
    },
)
webhook = response.json()
# Store webhook["secret"] now - it is never returned again.

Response

json
{
  "id": "whk_01JXYZ...",
  "url": "https://example.com/hooks/orkestr",
  "event_types": ["sandbox.expired", "sandbox.paused"],
  "active": true,
  "consecutive_failures": 0,
  "last_delivery_at": null,
  "last_error": null,
  "created_at": "2026-07-16T14:00:00+00:00",
  "secret": "whsec_4f2a..."
}
The secret is shown once
secret appears only in this create response. Store it - every delivery is signed with it, and it cannot be retrieved later (delete the webhook and register a new one to rotate).

Deliveries

Each delivery is a POST with three headers: X-Orkestr-Event (the event name), X-Orkestr-Delivery (a UUID that stays the same across retries of one delivery - use it to deduplicate), and X-Orkestr-Signature.

bash
POST /hooks/orkestr HTTP/1.1
Content-Type: application/json
User-Agent: orkestr-webhooks/1.0
X-Orkestr-Event: sandbox.expired
X-Orkestr-Delivery: 6b1f2a34-...
X-Orkestr-Signature: sha256=8f1c...

{
  "event": "sandbox.expired",
  "delivery_id": "6b1f2a34-...",
  "timestamp": "2026-07-16T14:05:00+00:00",
  "data": {
    "sandbox_id": "sbx_01HXYZ...",
    "outcome": "ok",
    "summary": null,
    "detail": {"from": "on_timeout", "snapshot_id": "snap_..."}
  }
}

Verify the signature

The signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your webhook's secret. The SDKs ship this as verify_webhook_signature (Python) / verifyWebhookSignature (JS) with constant-time comparison built in; by hand it is:

python
import hashlib, hmac

def verify(secret: str, raw_body: bytes, signature_header: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)

# In your handler: compute over the RAW request bytes, before any JSON
# parsing or re-serialization - the signature covers the exact body sent.

Retries and disabling

Respond with any 2xx within 10 seconds to acknowledge. Anything else is retried up to 3 attempts, 10 seconds apart; redirects are not followed. A webhook that fails 20 deliveries in a row is disabled automatically - GET /v1/sandboxes/webhooks shows active: false and the last error - and stays off until you call POST /v1/sandboxes/webhooks/{id}/enable, which also resets the failure counter.

Custom templates

Build a reusable image with your dependencies preinstalled, then boot sandboxes from it in ~300 ms. Building is a control-plane operation - the SDKs consume templates but don't build them. The Custom templates guide covers the full flow.

Build a template

POST /v1/templates requires sandboxes:write and a paid plan. The build is asynchronous - the response returns immediately with status: "building".

bash
curl -X POST https://api.orkestr.eu/v1/templates \
  -H "Authorization: Bearer $ORKESTR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "data-stack",
    "base_template": "python-3.12",
    "recipe": ["pip install pandas numpy"],
    "network": "restricted"
  }'
ParameterTypeDescription
name requiredstringHuman-friendly template name.
base_template requiredstringBuilt-in base image to build on (python-3.12, etc.).
recipe requiredstring[]Ordered shell steps, each run as root with HOME=/root. Installs persist into the captured image.
networkstringEgress during the build: restricted (default) or open.
descriptionstringOptional.
tagsstring[]Tags assigned when the build goes ready (e.g. ["v2"]). The first successful build of a name also claims default automatically.
start_commandstringOptional command run once, detached, on the first boot of every sandbox from this template (cwd /workspace, sandbox env). Does not re-run on resume. Non-fatal - the create response returns a start_command_id to poll via the background-commands API.

Response

json
{
  "id": "tmpl_01J...",
  "name": "data-stack",
  "base_template": "python-3.12",
  "recipe": ["pip install pandas numpy"],
  "status": "building",
  "error_message": null,
  "size_mb": null,
  "tags": [],
  "start_command": null,
  "created_at": "2026-06-16T12:00:00Z",
  "built_at": null
}

Poll GET /v1/templates/{id} until status is ready (or failed, with error_message). GET /v1/templates lists yours, and DELETE /v1/templates/{id} removes a template and its image. Boot from a ready template by passing the template on POST /v1/sandboxes as its name (resolves the default tag), name:tag (pins a tag), or the tmpl_ id (pins that build).

Tags

Builds are immutable; tags are movable pointers within a template name. PUT /v1/templates/{id}/tags/{tag} points a tag at a ready build - moving it from another build of the same name if it exists (promotion without a rebuild). DELETE /v1/templates/{id}/tags/{tag} removes it. Each template's current tags appear on the list/get responses.

Errors

Error bodies are JSON with at minimum a detail string. Many also carry a machine-readable code - dispatch on that when present, fall back to the HTTP status code otherwise.

json
{
  "detail": "API token missing required scope(s): sandboxes:write",
  "code": "missing_scope"
}
ParameterTypeDescription
401 UnauthorizedMissing, invalid, or expired API token.
403 ForbiddenToken valid but lacks the required scope.
404 Not FoundSandbox id doesn't exist or doesn't belong to the caller.
409 ConflictOperation invalid in current state. code: snapshot_cap_reached for the pause-over-cap case.
429 Too Many RequestsPlan rate limit hit. retry_after (seconds) included in body.
5xxServer error. Retries with backoff are appropriate; capture x-request-id from the response for support tickets.

Request IDs

Every response carries an x-request-id header. Include it in support tickets so we can correlate against server logs.

Reading with an agent? This page is also plain markdown at /docs/sandboxes/api-reference.md, and the full docs index lives at /docs/llms.txt.