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.
snake_case field names. Errors return a FastAPI-style {"detail": "..."} body plus an optional code field for machine-readable dispatch.Base URL
https://api.orkestr.eu/v1Authentication
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.
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 sandboxGET /v1/sandboxes- list yours; filter withstatusand repeatedmetadata[key]=value(AND, exact match), cursor-paged vianext_cursor/cursorGET /v1/sandboxes/limits- sizes and caps your plan allowsGET /v1/sandboxes/{id}- get oneGET /v1/sandboxes/{id}/metrics- live CPU + memoryGET /v1/sandboxes/events- account-wide activity feed across all your sandboxes, newest first; filter withsandbox_id/ repeatedevent_type, page withbefore, poll withsincePOST /v1/sandboxes/webhooks- register a lifecycle webhook (signed POSTs instead of polling)GET /v1/sandboxes/webhooks- list yoursPOST /v1/sandboxes/webhooks/{id}/enable- re-enable one that was disabled after repeated failuresDELETE /v1/sandboxes/webhooks/{id}- deleteGET /v1/sandboxes/{id}/host?port=N- public URL for a portDELETE /v1/sandboxes/{id}- terminatePOST /v1/sandboxes/{id}/exec- run a commandPOST /v1/sandboxes/{id}/code- run code in a persistent kernel (code-interpreter template)POST /v1/sandboxes/{id}/code/contexts- create an isolated kernel contextGET /v1/sandboxes/{id}/code/contexts- list kernel contextsPOST /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 contextPOST /v1/sandboxes/{id}/commands- start a background command (returns a handle)GET /v1/sandboxes/{id}/commands[/{cmd}]- list / poll background commandsDELETE /v1/sandboxes/{id}/commands/{cmd}- kill (SIGTERM, then SIGKILL)GET /v1/sandboxes/{id}/commands/{cmd}/logs- buffered output;follow=truefor the live SSE streamPOST /v1/sandboxes/{id}/files- write fileGET /v1/sandboxes/{id}/files?path=...- read file or list dirDELETE /v1/sandboxes/{id}/files?path=...- delete filePOST /v1/sandboxes/{id}/files/mkdir- create a directoryPOST /v1/sandboxes/{id}/files/move- move / renameGET /v1/sandboxes/{id}/files/stat?path=...- stat / exists (exists=false, not 404)POST /v1/sandboxes/{id}/files/batch- write many files in one callPOST /v1/sandboxes/{id}/files/presign-download- mint a pre-signed download URLPOST /v1/sandboxes/{id}/files/presign-upload- mint a pre-signed upload URLPOST /v1/sandboxes/{id}/timeout- extend the lifetime (keep-alive)POST /v1/sandboxes/{id}/pause- snapshot + suspendPOST /v1/sandboxes/{id}/resume- restorePOST /v1/templates- build a custom templateGET /v1/templates- list yoursGET /v1/templates/{id}- get one (poll build status)DELETE /v1/templates/{id}- delete a template + its imagePUT /v1/templates/{id}/tags/{tag}- assign/move a tag to a buildDELETE /v1/templates/{id}/tags/{tag}- remove a tagPOST /v1/sandboxes/volumes- create a persistent volumeGET /v1/sandboxes/volumes- list yoursPOST /v1/sandboxes/volumes/{id}/move- move a detached volume to another regionDELETE /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()| 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
{
"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)
{
"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.
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.
Request
{
"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).
{
"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
{
"path": "/workspace/main.py",
"is_dir": false,
"content": "cHJpbnQoMisxKQ==",
"size": 10
}Directory response
{
"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.
{
"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
{
"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.
# 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"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
{
"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.
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
{
"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.
{
"port": 3000,
"host": "3000-01hxyz....sbx.orkestr.run",
"url": "https://3000-01hxyz....sbx.orkestr.run"
}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'sdetailsays which)sandbox.expired- the sandbox aged out on its own: the timeout hit (whetheron_timeoutpaused or terminated it) or a paused snapshot reached its retention limit. Distinct fromsandbox.terminatedso you can tell "ran out of time" from "was stopped".template.build_finished- a custom template build resolved;data.statusisreadyorfailed(no need to pollGET /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
{
"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..."
}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.
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:
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".
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
{
"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.
{
"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.