# 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.