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/v1

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

ScopeGrants
sandbox:readList/get sandboxes, processes, filesystem reads, snapshots (read)
sandbox:writeCreate/pause/resume/fork/delete sandboxes, exec processes, filesystem writes
device:readList/get device sessions and their state
device:interactInstall apps, tap/type/screenshot, control device sessions
runner:adminCreate/scale/delete runner pools, manage JIT registration
build:writeTrigger and cancel builds
billing:readRead 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. Poll GET /v1/operations/{operation} or subscribe to /v1/events to observe completion.
  • replayed, true if 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.codeMeaning
sandbox_already_pausedPause called on a sandbox that isn't running
sandbox_already_runningResume called on a sandbox that isn't paused
fork_source_busyFork requested while the source sandbox is mid-snapshot
runner_pool_scalingPool mutation requested while a previous scale operation is in flight
device_session_lockedInteract call sent while another client holds the device session lock
idempotency_key_reused_with_different_bodySame 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.resourceMeaning
sandbox_concurrencyMax concurrent running sandboxes for the project
sandbox_create_rateSandbox creation rate limit (per minute)
device_concurrencyMax concurrent device sessions
runner_pool_capacityRunner pool size exceeds plan limit
api_request_rateGeneral per-key request rate limit
build_concurrencyMax concurrent builds

Endpoints#

EndpointDescription
/sandboxesCreate, list, get, pause, resume, fork, delete microVM sandboxes
/processesExec commands inside a sandbox, stream stdout/stderr, get exit status
/device-sessionsCreate and control iOS/Android simulator sessions (install, tap, screenshot, stream)
/runner-poolsManage GitHub Actions runner pools (Linux/Windows/macOS), warm counts, labels
/buildsTrigger and inspect CI/build runs against a runner pool
/snapshotsList, create, and restore per-minute incremental encrypted snapshots
/operationsPoll the status of an async mutation by operation ID
/eventsCursor-based event stream for a project (sandbox lifecycle, build, device, billing events)
/usageQuery 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 as cursor to fetch the next page. null when has_more is false.

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

Use it to generate typed clients in languages we don't ship an SDK for, or to feed into API tooling (Postman, Stainless, etc.).