API Reference
REST v1 overview, base URL, auth, idempotency, errors, and the full endpoint table for the Sandal API.
Sandal exposes a single REST API that every SDK (@sandal/sdk, sandal for Python, the Go client, and the CLI) is a thin wrapper around. If the SDKs don't cover something, the REST API does.
Base URL#
https://api.sandal.dev/v1Every response is JSON. Every request that mutates state must be idempotent (see below).
Authentication#
All requests are authenticated with a bearer API key in the Authorization header:
curl https://api.sandal.dev/v1/sandboxes \
-H "Authorization: Bearer sandal_sk_v1_..."Keys are prefixed sandal_sk_v1_ and are created and scoped from the dashboard (Settings → API Keys). A key carries one or more scopes; requests fail with 403 if the key's scopes don't cover the endpoint.
| Scope | Grants |
|---|---|
sandbox:read | List/get sandboxes, processes, filesystem reads, snapshots (read) |
sandbox:write | Create/pause/resume/fork/delete sandboxes, exec processes, filesystem writes |
device:read | List/get device sessions and their state |
device:interact | Install apps, tap/type/screenshot, control device sessions |
runner:admin | Create/scale/delete runner pools, manage JIT registration |
build:write | Trigger and cancel builds |
billing:read | Read usage and billing records |
Keys are always scoped to a single project. There is no "admin key" that spans projects, create a key per project per integration.
Idempotency#
Every mutating request (POST, PATCH, DELETE) requires an Idempotency-Key header. Sandal stores the key alongside the resulting operation for 24 hours; replaying the same key with the same request body returns the original result instead of performing the action again.
curl -X POST https://api.sandal.dev/v1/sandboxes \
-H "Authorization: Bearer $SANDAL_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"template": "node-24"}'Requests without an Idempotency-Key on a mutating endpoint are rejected with 400 idempotency_key_required.
202 + MutationResult#
Mutations that can't complete synchronously (cold sandbox create, fork, device provisioning, runner pool scaling) return 202 Accepted with a MutationResult envelope:
{
"resource": "sandbox",
"operation": "op_9f2c1e4a",
"replayed": false
}resource, the resource type being mutated (sandbox,device_session,runner_pool,build,snapshot).operation, an operation ID. PollGET /v1/operations/{operation}or subscribe to/v1/eventsto observe completion.replayed,trueif this response was served from an idempotency-key replay rather than a fresh mutation.
Fast-path mutations (pause, delete on an already-warm sandbox, etc.) may return 200/204 directly instead of 202, check the status code, don't assume 202.
Errors#
Errors use a consistent envelope:
{
"error": {
"type": "conflict",
"code": "sandbox_already_paused",
"message": "Sandbox sb_8f3a is already paused.",
"request_id": "req_4c9a1f"
}
}Standard HTTP status codes apply (400 validation, 401 auth, 403 scope, 404 not found, 409 conflict, 422 semantic validation, 429 rate/quota limit, 5xx server error).
409 conflict reasons#
error.code | Meaning |
|---|---|
sandbox_already_paused | Pause called on a sandbox that isn't running |
sandbox_already_running | Resume called on a sandbox that isn't paused |
fork_source_busy | Fork requested while the source sandbox is mid-snapshot |
runner_pool_scaling | Pool mutation requested while a previous scale operation is in flight |
device_session_locked | Interact call sent while another client holds the device session lock |
idempotency_key_reused_with_different_body | Same Idempotency-Key reused with a different request payload |
429 quota resources#
429 responses include error.code: quota_exceeded and an error.resource naming what was exhausted, plus a Retry-After header:
error.resource | Meaning |
|---|---|
sandbox_concurrency | Max concurrent running sandboxes for the project |
sandbox_create_rate | Sandbox creation rate limit (per minute) |
device_concurrency | Max concurrent device sessions |
runner_pool_capacity | Runner pool size exceeds plan limit |
api_request_rate | General per-key request rate limit |
build_concurrency | Max concurrent builds |
Endpoints#
| Endpoint | Description |
|---|---|
/sandboxes | Create, list, get, pause, resume, fork, delete microVM sandboxes |
/processes | Exec commands inside a sandbox, stream stdout/stderr, get exit status |
/device-sessions | Create and control iOS/Android simulator sessions (install, tap, screenshot, stream) |
/runner-pools | Manage GitHub Actions runner pools (Linux/Windows/macOS), warm counts, labels |
/builds | Trigger and inspect CI/build runs against a runner pool |
/snapshots | List, create, and restore per-minute incremental encrypted snapshots |
/operations | Poll the status of an async mutation by operation ID |
/events | Cursor-based event stream for a project (sandbox lifecycle, build, device, billing events) |
/usage | Query metered usage (active-CPU seconds, provisioned RAM, per-minute device/runner charges) |
Full parameter and response schemas for each are in the OpenAPI spec.
Examples#
Create a sandbox#
curl -X POST https://api.sandal.dev/v1/sandboxes \
-H "Authorization: Bearer $SANDAL_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"template": "node-24"
}'{
"resource": "sandbox",
"operation": "op_9f2c1e4a",
"replayed": false
}Poll the operation, or use the sandbox_id once available:
curl https://api.sandal.dev/v1/operations/op_9f2c1e4a \
-H "Authorization: Bearer $SANDAL_API_KEY"Exec inside a sandbox#
curl -X POST https://api.sandal.dev/v1/sandboxes/sb_8f3a/processes \
-H "Authorization: Bearer $SANDAL_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"command": "pnpm test"
}'Fork a sandbox#
curl -X POST https://api.sandal.dev/v1/sandboxes/sb_8f3a/fork \
-H "Authorization: Bearer $SANDAL_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"{
"resource": "sandbox",
"operation": "op_1a77bd02",
"replayed": false
}Forks reuse the parent's most recent incremental snapshot and lazily restore with prefetch, so a fork of a warm sandbox typically resolves in well under a second.
Pagination#
List endpoints (/sandboxes, /processes, /device-sessions, /runner-pools, /builds, /snapshots) are cursor-paginated:
curl "https://api.sandal.dev/v1/sandboxes?limit=50&cursor=$CURSOR" \
-H "Authorization: Bearer $SANDAL_API_KEY"{
"data": [ ... ],
"next_cursor": "eyJpZCI6InNiXzhmM2EifQ",
"has_more": true
}limit, max 100, default 20.next_cursor, pass back ascursorto fetch the next page.nullwhenhas_moreisfalse.
Event cursors#
GET /v1/events uses the same cursor shape but is designed for long-polling or tailing:
curl "https://api.sandal.dev/v1/events?cursor=$CURSOR&limit=100" \
-H "Authorization: Bearer $SANDAL_API_KEY"Store next_cursor durably (per consumer) and pass it back on the next call, cursors are stable across restarts, so this is safe to use as a resume token for a queue consumer. Events are ordered but not deduplicated across retries; consumers should treat event id as the dedupe key.
OpenAPI#
The full machine-readable spec, every path, parameter, request/response schema, and error shape, is published at:
https://api.sandal.dev/openapi/sandal.v1.jsonUse it to generate typed clients in languages we don't ship an SDK for, or to feed into API tooling (Postman, Stainless, etc.).