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

Seventeen endpoints across lifecycle, exec, files, metrics, port exposure, pause / resume, and custom templates:

  • POST /v1/sandboxes - create a sandbox
  • GET /v1/sandboxes - list yours
  • 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/{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}/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}/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
  • 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
templaterequiredstringA built-in - python-3.12, python-3.12-bare, node-22, debian-12 - 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 - 3600).
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": 2048,
  "region": "fsn1",
  "created_at": "2026-05-19T14:22:11Z",
  "expires_at": "2026-05-19T14:32:11Z",
  "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.

Files

Three endpoints sharing the path under /v1/sandboxes/{id}/files. All file content is base64 over the wire so binary round-trips cleanly. Reads and writes are capped at 16 MiB per request.

Write a file

POST /v1/sandboxes/{id}/files requires sandboxes:write. Writes go to /workspace or /tmp.

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.

Pause and resume

POST /v1/sandboxes/{id}/pause snapshots the sandbox and stops the compute meter. Returns the sandbox id and the server-side snapshot id; pass the sandbox id back to /resume when you want to come back.

Pause response

json
{
  "sandbox_id": "sbx_01HXYZ...",
  "snapshot_id": "snap_...",
  "status": "paused",
  "snapshot_size_mb": 1024,
  "created_at": "2026-05-19T14: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.

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": 536870912, "usage_bytes": 188743680, "usage_percent": 35.2 },
  "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.

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
namerequiredstringHuman-friendly template name.
base_templaterequiredstringBuilt-in base image to build on (python-3.12, etc.).
reciperequiredstring[]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.

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,
  "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 its id as the template on POST /v1/sandboxes.

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.