# REST API reference The sandbox REST API lives at `https://api.orkestr.eu/v1`. Use it directly from any language, or pick the [Python](https://orkestr.eu/docs/sandboxes/python-sdk) or [JS](https://orkestr.eu/docs/sandboxes/js-sdk) 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 **Python:** ```python 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() ``` **JavaScript:** ```javascript const response = await fetch("https://api.orkestr.eu/v1/sandboxes", { method: "POST", headers: { Authorization: `Bearer ${process.env.ORKESTR_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ template: "python-3.12", size: "small", network: "off", timeout_seconds: 600, env: { OPENAI_API_KEY: "sk-..." }, metadata: { agent_run: "r_42" }, }), }); const sandbox = await response.json(); ``` **cURL:** ```bash curl -X POST https://api.orkestr.eu/v1/sandboxes \ -H "Authorization: Bearer $ORKESTR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template": "python-3.12", "size": "small", "network": "off", "timeout_seconds": 600, "env": {"OPENAI_API_KEY": "sk-..."}, "metadata": {"agent_run": "r_42"} }' ``` | Parameter | Type | Description | | --- | --- | --- | | template required | string | A 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`. | | size | string | Fixed 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). | | network | string | `off`, `restricted`, or `open`. | | timeout_seconds | integer | Auto-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_timeout | string | What 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_resume | boolean | When `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_auth | boolean | When `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. | | env | object | Environment variables exposed to processes. | | metadata | object | Caller-defined tags echoed back on every response. Max 16 keys. | | region | string | Region code, e.g. `fsn1`. The current list is `regions` in `GET /v1/sandboxes/limits`. Omit for control-plane choice. | | allow_domains | string[] | 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`. | | volume | string | Attach 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 **Python:** ```python 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() ``` **JavaScript:** ```javascript const response = await fetch( "https://api.orkestr.eu/v1/sandboxes/sbx_01HXYZ.../exec", { method: "POST", headers: { Authorization: `Bearer ${process.env.ORKESTR_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ command: "python -c 'print(2+2)'", cwd: "/workspace", timeout_seconds: 60, stream: false, }), }, ); const result = await response.json(); ``` **cURL:** ```bash curl -X POST https://api.orkestr.eu/v1/sandboxes/sbx_01HXYZ.../exec \ -H "Authorization: Bearer $ORKESTR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "command": "python -c \"print(2+2)\"", "cwd": "/workspace", "timeout_seconds": 60, "stream": false }' ``` ### 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: | Parameter | Type | Description | | --- | --- | --- | | exec_started | event | Sent once at the start. Carries the in-VM `pid`. | | exec_chunk | event | Output burst. Has `stream` (stdout / stderr) and base64-encoded `data`. | | exec_exited | event | Final frame. Carries `code` and `duration_ms`. | | error | event | Error 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](https://orkestr.eu/docs/sandboxes/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": "...
", "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. **Python:** ```python 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, }, ) ``` **JavaScript:** ```javascript await fetch( `https://api.orkestr.eu/v1/sandboxes/${sandboxId}/files`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ path: "/workspace/main.py", content: Buffer.from("print(2+1)").toString("base64"), mode: 0o644, }), }, ); ``` **cURL:** ```bash curl -X POST https://api.orkestr.eu/v1/sandboxes/sbx_01HXYZ.../files \ -H "Authorization: Bearer $ORKESTR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "path": "/workspace/main.py", "content": "cHJpbnQoMisxKQ==", "mode": 420 }' ``` ### 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. **Python:** ```python 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() ] }, ) ``` **JavaScript:** ```javascript await fetch( `https://api.orkestr.eu/v1/sandboxes/${sandboxId}/files/batch`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ files: [ { path: "/workspace/src/app.py", content: Buffer.from("print('hi')").toString("base64") }, { path: "/workspace/README.md", content: Buffer.from("# my app").toString("base64") }, { path: "/etc/motd", content: Buffer.from("oops").toString("base64") }, ], }), }, ); ``` **cURL:** ```bash curl -X POST https://api.orkestr.eu/v1/sandboxes/sbx_01HXYZ.../files/batch \ -H "Authorization: Bearer $ORKESTR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "files": [ {"path": "/workspace/src/app.py", "content": "cHJpbnQoJ2hpJyk="}, {"path": "/workspace/README.md", "content": "IyBteSBhcHA=", "mode": 420}, {"path": "/etc/motd", "content": "b29wcw=="} ] }' ``` ### 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). **Python:** ```python 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() ``` **JavaScript:** ```javascript const response = await fetch( `https://api.orkestr.eu/v1/sandboxes/${sandboxId}/files/presign-download`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ path: "/workspace/out/report.pdf", expires_in: 600, }), }, ); const presigned = await response.json(); ``` **cURL:** ```bash curl -X POST https://api.orkestr.eu/v1/sandboxes/sbx_01HXYZ.../files/presign-download \ -H "Authorization: Bearer $ORKESTR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"path": "/workspace/out/report.pdf", "expires_in": 600}' ``` ### 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 `-.sbx.orkestr.run`, where `` 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 `.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`. **Python:** ```python 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. ``` **JavaScript:** ```javascript const response = await fetch( "https://api.orkestr.eu/v1/sandboxes/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.ORKESTR_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://example.com/hooks/orkestr", event_types: ["sandbox.paused", "sandbox.expired"], }), }, ); const webhook = await response.json(); // Store webhook.secret now - it is never returned again. ``` **cURL:** ```bash curl -X POST https://api.orkestr.eu/v1/sandboxes/webhooks \ -H "Authorization: Bearer $ORKESTR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/orkestr", "event_types": ["sandbox.paused", "sandbox.expired"] }' ``` ### 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](https://orkestr.eu/docs/sandboxes/python-sdk)) / `verifyWebhookSignature` ([JS](https://orkestr.eu/docs/sandboxes/js-sdk)) 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](https://orkestr.eu/docs/sandboxes/templates) 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" }' ``` | Parameter | Type | Description | | --- | --- | --- | | name required | string | Human-friendly template name. | | base_template required | string | Built-in base image to build on (`python-3.12`, etc.). | | recipe required | string[] | Ordered shell steps, each run as root with HOME=/root. Installs persist into the captured image. | | network | string | Egress during the build: `restricted` (default) or `open`. | | description | string | Optional. | | tags | string[] | Tags assigned when the build goes `ready` (e.g. `["v2"]`). The first successful build of a name also claims `default` automatically. | | start_command | string | Optional 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" } ``` | Parameter | Type | Description | | --- | --- | --- | | 401 Unauthorized | | Missing, invalid, or expired API token. | | 403 Forbidden | | Token valid but lacks the required scope. | | 404 Not Found | | Sandbox id doesn't exist or doesn't belong to the caller. | | 409 Conflict | | Operation invalid in current state. `code: snapshot_cap_reached` for the pause-over-cap case. | | 429 Too Many Requests | | Plan rate limit hit. `retry_after` (seconds) included in body. | | 5xx | | Server 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.