# Sandboxer API Error Codes, What They Mean and How to Fix Them

> Every Sandboxer API error is JSON with ok false, an HTTP status, a stable code, a message and a requestId. Branch on code. A 400 means fix the request, 401 or 403 the key or plan, 402 billing, 409 the sandbox's current state, 429 a limit, and 502 or 503 a machine or capacity problem worth retrying.

Source: https://sandboxer.com/guides/sandboxer-api-error-codes. Updated 2026-10-27.

**Short answer:** Every Sandboxer API error is JSON with ok false, an HTTP status, a stable code, a message and a requestId. Branch on code. A 400 means fix the request, 401 or 403 the key or plan, 402 billing, 409 the sandbox's current state, 429 a limit, and 502 or 503 a machine or capacity problem worth retrying.

This page lists the codes the public API (`https://sandboxer.com/api/v1`) returns for sandboxes, commands, files, hosting and snapshots, grouped by area. We built the list from the API source and triggered the ones marked "seen on prod" against the live API on 2026-10-27. The [limits and errors docs](/docs/limits) cover plan numbers and retry advice; the [API reference](/docs/api) covers every endpoint.

## The error envelope

Every error has the same shape. This is a real response for a sandbox id that doesn't exist:

```bash
curl -sS https://sandboxer.com/api/v1/sandboxes/sb_doesnotexist \
  -H "Authorization: Bearer $SANDBOXER_API_KEY"
```

```json
{
  "ok": false,
  "type": "sandbox.error",
  "status": 404,
  "code": "not_found",
  "message": "Sandbox not found",
  "error": { "code": "not_found", "message": "Sandbox not found", "status": 404 },
  "requestId": "req_DaUDmVYzggPo88jwzp2D"
}
```

- `code` is stable. Branch on it. `message` is for people and can change.
- `type` names the area: `sandbox.error`, `snapshot.error`, `account.error` and so on.
- `error.details` appears when there is more to say: the bad `key`, the `state` the sandbox was in, `retryable`, or the numbers behind a limit.
- `requestId` (also the `X-Request-Id` header) is what support needs to trace a failure. Log it.

The `sbx` CLI reports the same code and exits non-zero (3 for unauthorized, 4 for not found, 1 for most others). Run from a script (output not a terminal), it prints the error as a JSON event:

```bash
sbx fork sb_x581f2kt1s
```

```json
{"event":"error","error":"This sandbox was created with snapshots off and cannot be forked.","code":"snapshots_disabled","status":409,"requestId":"req_5HhAHp7T6Yj4vj2w76wm"}
```

## Authentication and key scope

| Code | Status | What it means and the usual cause | Fix |
| - | - | - | - |
| `unauthorized` | 401 | No key, or the key is wrong or revoked. Seen on prod. | Send `Authorization: Bearer $SANDBOXER_API_KEY` with a live key. |
| `api_key_expired` | 403 | The key had an expiry date and it passed. | Create a new key ([API keys](/docs/api-keys)). |
| `api_key_action_forbidden` | 403 | The key's actions don't include this one, for example `sandbox.delete` or `host`. `details.action` names it. | Use a key with that action, or widen the key. |
| `api_key_sandbox_forbidden` | 403 | The key is limited to certain sandboxes and this isn't one of them. | Use a key that covers this sandbox. |
| `api_key_environment_forbidden` | 403 | The key doesn't cover the environment you named. | Use a key scoped to that environment. |
| `sandbox_private` | 403 | A shared sandbox still holds its creator's logins. | Ask the creator to share it fully. |
| `owner_only` | 403 | Only the sandbox's creator can do this (fork, delete, change its environment). | Run it as the creator. |
| `session_required` | 403 | The endpoint takes a dashboard or CLI session, not an API key. | Do it from the dashboard or `sbx`. |
| `account_suspended`, `account_closed` | 403 | The account can't act right now. A closed account can be reopened on the dashboard's Account page. | Reopen it, or contact support with the `requestId`. |

Real response with no key at all:

```json
{"code":"unauthorized","error":{"code":"unauthorized","message":"Unauthorized","status":401},"message":"Unauthorized","ok":false,"requestId":"req_Zp2VPL3AanwQmY5PncaB","status":401,"type":"sandbox.error"}
```

## Request validation (400, 413, 415)

These never succeed on retry. Change the request.

| Code | Status | What it means and the usual cause | Fix |
| - | - | - | - |
| `invalid_json` | 400 | The body isn't valid JSON, or a required body is empty. `details.reason` has the parser's message. Seen on prod. | Send a complete JSON object. |
| `unsupported_media_type` | 415 | A body was sent without a Bearer key and without `Content-Type: application/json`. | Set the header. |
| `invalid_type` | 400 | `type` isn't `small`, `default`, `large` or `xlarge`. Seen on prod. | Use one of the four sizes. |
| `invalid_ttl` | 400 | `ttlSeconds` is outside 1 to 2,592,000 (30 days). Seen on prod. | Pass a value in range, or `null`. |
| `invalid_env` | 400 | A bad variable name (`details.key`), a reserved name, more than 100 variables or over 64 KB in total. Seen on prod. | Use names like `MY_VAR` that don't start with a digit, and stay under the limits. |
| `unknown_environment` | 400 | No environment has the name you passed. Seen on prod. | Check `sbx env list`. |
| `invalid_setup_script` | 400 | `setupScript` isn't a string, or is over 64 KB. | Shorten it, or fetch the rest from inside the script. |
| `invalid_idle_stop` | 400 | `idleStopMinutes` is out of range. | `0` means never; otherwise stay in the allowed range. |
| `trial_auto_stop_required` | 400 | On the free trial a sandbox must auto-stop, and the TTL was missing or too long. | Pass a shorter `ttlSeconds`, or add a payment method. |
| `command_required` | 400 | `POST /commands` without `command`. Seen on prod. | Send `{"command": "..."}`. |
| `invalid_timeout` | 400 | `timeoutSeconds` isn't a whole number from 1 to 600. Seen on prod. | Use 600 or less, or run long work detached (`"detached": true`). |
| `invalid_cwd` | 400 | `cwd` is too long. | Use a shorter path. |
| `invalid_path` | 400 | A file path that resolves outside `/home/user` or `/tmp`. Seen on prod. | Read and write under those two directories. |
| `invalid_encoding` | 400 | File `encoding` isn't `utf8` or `base64`. | Use one of the two. |
| `file_too_large` | 413 | File reads and writes cap at 5 MiB, artifacts at 50 MiB. | Use `GET /sandboxes/{id}/artifacts`, or `sbx scp` for bigger files. |
| `invalid_port` | 400 | Port missing or outside 1 to 65535. Seen on prod. | Pass the port your server listens on. |
| `invalid_name` | 400 | A named snapshot name isn't 1 to 63 lowercase letters, digits or dashes, or is reserved. Seen on prod. | Use something like `web-stack`. |
| `invalid_labels` | 400 | A label key has characters outside letters, digits, `.`, `-`, `_`, `/`. | Fix the key. |
| `invalid_cursor` | 400 | A pagination cursor that wasn't returned by the API. | Pass `nextCursor` as you got it. |
| `invalid_idempotency_key` | 400 | `Idempotency-Key` is longer than 255 characters. | Use a UUID. |
| `no_changes` | 400 | `PATCH /sandboxes/{id}` with nothing to change. | Pass at least one field. |
| `type_too_small` | 400 | A resume or fork asked for a size whose disk the sandbox's data won't fit. Moving to a smaller size isn't supported yet. | Keep the size, or create a new sandbox of the size you want and copy what you need ([resize guide](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox)). |

Two of them from prod, trimmed to the inner `error` object:

```json
{"code":"invalid_env","details":{"key":"1BAD"},"message":"Invalid environment variable name \"1BAD\".","status":400}
{"code":"invalid_timeout","message":"timeoutSeconds must be an integer between 1 and 600.","status":400}
```

## Not found (404)

| Code | Status | What it means and the usual cause | Fix |
| - | - | - | - |
| `not_found` | 404 | No sandbox with that id, it was deleted, or it belongs to another account. A mistyped path gives the same code with `No route for GET /api/v1/...`. Seen on prod. | Check the id with `sbx list` and the path against the [API reference](/docs/api). |
| `file_not_found` | 404 | Reading a file (`GET /sandboxes/{id}/files?path=`) or downloading an artifact (`GET /sandboxes/{id}/artifacts?path=`) at a path that doesn't exist. `details.retryable` is false. Seen on prod since 2026-10-27; before that, this returned a retryable 502 `sandbox_direct_failed`. Browsing a snapshot's files still returns `not_found`. | Check the path, for example with `sbx exec <id> "ls <dir>"`. Don't retry. |
| `snapshot_not_found` | 404 | No named snapshot with that name, a wrong or revoked share code, or no ready snapshot carrying all the `fromLabels`. Seen on prod. | List them with `GET /named-snapshots`. |

Another account's sandbox looks exactly like a missing one, on purpose.

## Sandbox state conflicts (409)

The request is fine but the sandbox isn't in a state that allows it. `details.state` often tells you which state it was in.

| Code | Status | What it means and the usual cause | Fix |
| - | - | - | - |
| `sandbox_stopping` | 409 | Resume sent while the stop is still writing the snapshot (`archiving`). Seen on prod. | Wait for `archived` (`sbx stop --wait`), then resume. `sbx resume` retries this one for you. |
| `machine_not_running` | 409 or 400 | A command or file call on a stopped sandbox (409, `details.retryable: false`). Hosting a port or adding an SSH key on a stopped sandbox returns the same code with 400. Seen on prod, both. | Resume first. |
| `sandbox_starting` | 409 | Still starting after the API waited 60 seconds for it. `details.retryable: true`. | Retry in a few seconds. |
| `sandbox_not_ready` | 409 | Can't run commands, fork or save while it is in a state such as `error`, `provisioning` or `archiving`. | Wait, resume, or `POST /sandboxes/{id}/recover`. |
| `sandbox_not_resumable` | 409 | Resume from a state that can't resume. | Check `state` with `GET /sandboxes/{id}`. |
| `sandbox_resuming` | 409 | A resume is already running. | Poll until `ready`. |
| `snapshots_disabled` | 409 | The sandbox was created with `--no-snapshots`, so it can't be resumed, forked or saved. Seen on prod. | Create sandboxes you want to keep without that flag. |
| `confirmation_required` | 409 | `DELETE` without the `X-Sandboxer-Confirm-Delete` header set to the id. Seen on prod. | Add the header, or use `sbx delete <id> --yes`. |
| `subdomain_taken`, `name_taken` | 409 | The subdomain or snapshot name already exists. | Pick another one. |
| `save_in_progress`, `snapshot_not_ready` | 409 | A save under that name is still running. | Wait for it to finish. |
| `version_conflict` | 409 | A versioned save with `ifLatest` that no longer matches. `details.latest` has the current number. | Re-read and retry with the new number. |
| `sandbox_in_cold_storage`, `snapshot_not_indexed` | 409 | The sandbox's data is in cold storage. | Resume it, then save or browse. |
| `idempotency_in_progress` | 409 | The first request with this `Idempotency-Key` is still running. | Retry shortly with the same key. |
| `idempotency_key_reused` | 409 | Same `Idempotency-Key`, different body. | Use a new key for a new request. |
| `monitoring_off`, `monitoring_off_for_account`, `snapshots_off_for_account` | 409 | The feature is turned off for this sandbox or account. | Turn it on, or don't depend on it. |
| `no_running_turn` | 409 | Steer or interrupt with no agent turn running. | Send a prompt instead. |
| `card_check_pending` | 409 | A newly added card is still being checked. | Retry in a few seconds. |

The stop-then-resume race, as it happened on prod (resume sent right after `POST /stop` returned 202):

```json
{"code":"sandbox_stopping","message":"The sandbox is still stopping; resume it once it is archived.","status":409}
```

A command sent at the same moment:

```json
{"code":"machine_not_running","details":{"retryable":false,"state":"archiving"},"message":"The sandbox is stopped. Resume it first.","status":409}
```

## Billing, plan and limits (402, 403, 429)

| Code | Status | What it means and the usual cause | Fix |
| - | - | - | - |
| `payment_method_required` | 402 | No card on file. `details` has `checkoutUrl` and `trialEligible`. | Add a card on the dashboard's Billing page. |
| `trial_expired` | 402 | The 7-day trial ended. | Pick a plan. |
| `billing_required` | 402 | Out of machine time, or an organization with no plan or credit. | Add credit or upgrade. |
| `storage_limit_reached` | 402 | Stored data is over the plan's storage. `details` has `usedBytes` and `includedBytes`. Stop, resume and delete still work. | Delete stopped sandboxes or named snapshots, or upgrade. |
| `team_member_cap_reached` | 402 | You hit the usage cap your organization set for you. | Ask an org owner. |
| `trial_machine_class_not_allowed` | 403 | That size needs a payment method. | Add a card, or use a smaller size. |
| `machine_class_plan_required` | 403 | The size needs a higher plan (`xlarge`). | Upgrade, or use `large`. |
| `limit_reached` | 429 | Too many sandboxes active at once for your plan. `details` has `limit` and `used`. | Stop or delete one. Retrying won't help. |
| `member_limit_reached` | 429 | Your organization caps how many you run at once. | Stop one, or ask an org owner. |
| `rate_limited` | 429 | Too many starts in the minute or hour window, or too many requests overall (`details.window: "requests"`). | Wait. Start limits send `Retry-After` and `details.resetsAt`. |
| `daily_limit_reached` | 429 | The rolling 24-hour start limit is used up. | Wait until `details.resetsAt`. |

Creates, resumes and forks each count as a start. Check how many you have left before a burst with `sbx limits --json`, as shown in the [limits docs](/docs/limits#starts).

## Machines and capacity (502, 503)

| Code | Status | What it means and the usual cause | Fix |
| - | - | - | - |
| `sandbox_direct_failed` | 502 | The call reached the sandbox's machine but didn't complete. `details.retryable` says whether a retry is safe. | Retry only when `retryable` is true. A command may already have run. |
| `no_ready_machine` | 503 | Only with `failFast: true`: no machine of that size was ready, so nothing was created and no start was counted. | Retry, or fall back to another size. |
| `capacity_exceeded` | 503 | Not enough room for that size right now. `details.available` and `details.availableBySize` say how many fit. | Try a smaller size, or again shortly. |
| `http_500` | 500 | An unexpected server error. | Retry with backoff, and send the `requestId` to support if it repeats. |

## What to retry

- Retry with backoff: `sandbox_starting`, `sandbox_stopping`, `idempotency_in_progress`, `card_check_pending`, `capacity_exceeded`, `no_ready_machine`, `http_500`, and `sandbox_direct_failed` when `details.retryable` is true.
- Wait, then retry: `rate_limited` and `daily_limit_reached`, until `Retry-After` or `details.resetsAt`.
- Don't retry without changing something: every 400, 401, 402, 403, 404, `limit_reached`, `snapshots_disabled`.
- Send an `Idempotency-Key` on `POST /sandboxes` so a retried create after a timeout returns the same sandbox instead of a second one.

## FAQ

### Why do I get not_found and not sandbox_not_found?

The API returns `not_found` for a missing, deleted or other account's sandbox, which is what we saw on prod. Older examples may show `sandbox_not_found`; branch on the 404 status plus `not_found`.

### Is a 409 worth retrying?

Some are. `sandbox_starting`, `sandbox_stopping`, `idempotency_in_progress` and `card_check_pending` clear on their own. The others, like `snapshots_disabled` or `idempotency_key_reused`, need a different request.

### How do I avoid sandbox_stopping after a stop?

Use `sbx stop <id> --wait`, or poll `GET /sandboxes/{id}` until `state` is `archived`, before you resume. The stop call returns while the memory snapshot is still being written.

### What should I send support?

The `requestId` from the error body or the `X-Request-Id` header, the code, and roughly when it happened.

## Related

- [Limits and errors](/docs/limits)
- [API reference](/docs/api)
- [Pause and resume a cloud sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory)
- [Fix exit code 137 or a full disk in a sandbox](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox)
