diff --git a/api-reference-v2/sandboxes/create-a-sandbox.mdx b/api-reference-v2/sandboxes/create-a-sandbox.mdx
new file mode 100644
index 000000000..5ecfac317
--- /dev/null
+++ b/api-reference-v2/sandboxes/create-a-sandbox.mdx
@@ -0,0 +1,215 @@
+---
+title: "Create a sandbox"
+api: "POST https://api.runpod.io/v2/sandboxes"
+authMethod: "bearer"
+description: "Create an isolated, fast-starting Runpod CPU sandbox from an image, template, or the default image, with timeouts, ports, volumes, and labels."
+---
+
+Launches a new sandbox: an isolated, fast-starting CPU environment for running untrusted code. Supply at most one of `templateId` or `image`; with neither, the sandbox runs the default sandbox image. Every field is optional, so an empty body (`{}`) is a valid request. Omitted fields use your account's default sandbox policy.
+
+The request returns `201` with the sandbox, usually still in the `CREATING` state. Poll [Get a sandbox](/api-reference-v2/sandboxes/get-a-sandbox) until `state` is `RUNNING`.
+
+The sandbox is billed per second until it terminates. Terminate it explicitly when you're finished, including on error paths, rather than relying on a timeout to stop billing.
+
+### Headers
+
+
+ Makes the create safe to retry after a lost response. Repeating the request with the same key and the same body returns the sandbox the first request created (`201`) instead of creating another. The same key with a different body is a `400`. A key whose first create failed is spent: retrying with it returns `409`, so retry a failed create with a new key. Keys are unique per account and compared case-insensitively.
+
+
+### Body
+
+
+ Container image to run, for example `python:3.12-slim`. Can't be combined with `templateId`.
+
+
+
+ Sandbox template to launch from. Can't be combined with `image`.
+
+
+
+ ID of one of your saved container registry credentials (from `/v2/registries`), for pulling a private image. An ID that isn't yours is rejected.
+
+
+
+ Human-readable name. Names don't have to be unique. Generated when omitted.
+
+
+
+ CPU flavor and vCPU count, as on a CPU Pod. Memory and container disk limits scale with the flavor. Defaults to the standard sandbox flavor and size.
+
+
+
+ CPU flavor ID, for example `cpu3c`.
+
+
+
+ Number of vCPUs.
+
+
+
+
+
+
+**Review question:** Besides `id` and `vcpuCount`, which fields does `cpu` accept on create? Is `cpu.memory` read-only? _Remove this box before merging._
+
+
+
+
+ Container disk in GB. Ephemeral: deleted when the sandbox terminates. Capped per vCPU, the same limit CPU Pods have.
+
+
+
+ Preferred data centers, each one that offers sandboxes. Omit to let Runpod choose. With a network volume mounted, omit it or include the volume's data center.
+
+
+
+ Environment variables as string key-value pairs. Use `{{ RUNPOD_SECRET_name }}` to reference a Runpod secret. Values are returned as written in API responses, so don't put plain credentials here.
+
+
+
+ Container CMD in exec form, for example `["python", "-m", "http.server", "8080"]`. If the image defines an ENTRYPOINT, this is the argument list passed to it. Omit `cmd` and `entrypoint` to keep the image's own; an image with no command runs a keepalive process.
+
+
+
+ Container ENTRYPOINT in exec form, overriding the image's own.
+
+
+
+ Ports to expose, at most 15. Each entry has a `port` and a `protocol` (`http`, `tcp`, or `udp`). HTTP ports are reached through a proxy URL; TCP and UDP ports through a public IP and port. See [Exposed ports](/sandboxes/overview#exposed-ports).
+
+
+
+ Volumes to mount.
+
+
+
+ Network volumes: `volumeId` (required) and `path` (defaults to `/workspace`).
+
+
+
+ Global volumes: `volumeId` (required) and `path` (defaults to `/workspace-global`).
+
+
+
+
+
+ Inject the SSH public keys registered on your account (`PUT /v2/account/ssh-keys`) so you can connect over SSH. Does nothing if you have no registered keys. Only images that start `sshd` from `PUBLIC_KEY` support it; all Runpod official images do. For a direct connection, also expose `22/tcp` in `ports`.
+
+
+
+ Seconds of inactivity before the sandbox is terminated. Omitted uses the default. Values above the maximum are clamped to it, and values under 90 are raised to 90. Read the response for the value applied.
+
+
+
+ Maximum total lifetime in seconds, counted from creation. Omitted or out-of-range values are clamped to the account policy's default and maximum.
+
+
+
+ String key-value labels for organizing sandboxes. You can filter [List sandboxes](/api-reference-v2/sandboxes/list-sandboxes) by label.
+
+
+
+ Outbound network policy: `ALLOW_ALL`, `ALLOW_LIST`, or `DENY_ALL`. Egress is open by default. Restriction isn't enabled for all accounts yet; until it is, any value other than `ALLOW_ALL` returns `400`.
+
+
+
+ Hostnames the sandbox may reach when `egressMode` is `ALLOW_LIST`. Requires restricted egress to be enabled for your account.
+
+
+{/* TODO: Document allowlist rules (exact lowercase hostnames, max 20, no IPs or wildcards) once host enforcement ships (AI-259). */}
+
+
+
+`imageName`, `cpuFlavorId`, `vcpuCount`, `memoryInGb`, and `dataCenterId` are deprecated aliases for `image`, `cpu.id`, `cpu.vcpuCount`, and `dataCenterIds`. Use the new names.
+
+
+
+### Response
+
+Returns the created sandbox. See [Get a sandbox](/api-reference-v2/sandboxes/get-a-sandbox) for all response fields.
+
+### Errors
+
+| Status | Meaning |
+| --- | --- |
+| `400` | The request is malformed, for example both `templateId` and `image` are set, an unknown field is included, or the account has reached its concurrent sandbox limit. |
+| `401` | The API key is missing or invalid. |
+| `402` | Your account balance is too low to start the sandbox. |
+| `403` | The API key doesn't have access to sandboxes. |
+| `409` | The `Idempotency-Key` was spent by a create that failed. Retry with a new key. |
+| `422` | The request is well-formed but can't be processed, for example an unknown template or flavor. |
+| `429` | Too many requests. Retry after a delay. |
+| `503` | No capacity is available for the requested configuration. Retry later, or remove placement constraints such as `dataCenterIds`. |
+
+
+
+```bash cURL
+curl --request POST \
+ --url https://api.runpod.io/v2/sandboxes \
+ --header "Authorization: Bearer $RUNPOD_API_KEY" \
+ --header "Content-Type: application/json" \
+ --header "Idempotency-Key: build-4821" \
+ --data '{
+ "name": "my-sandbox",
+ "image": "python:3.12-slim",
+ "idleTimeoutSeconds": 300,
+ "ports": [{"port": 8080, "protocol": "http"}],
+ "env": {"APP_ENV": "test"},
+ "labels": {"project": "checkout"}
+ }'
+```
+
+```python Python
+import os
+import requests
+
+response = requests.post(
+ "https://api.runpod.io/v2/sandboxes",
+ headers={
+ "Authorization": f"Bearer {os.environ['RUNPOD_API_KEY']}",
+ "Idempotency-Key": "build-4821",
+ },
+ json={
+ "name": "my-sandbox",
+ "image": "python:3.12-slim",
+ "idleTimeoutSeconds": 300,
+ "ports": [{"port": 8080, "protocol": "http"}],
+ "env": {"APP_ENV": "test"},
+ "labels": {"project": "checkout"},
+ },
+)
+print(response.status_code, response.json())
+```
+
+
+
+
+
+```json 201
+{
+ "id": "7h3sbx91kq",
+ "name": "my-sandbox",
+ "state": "CREATING",
+ "templateId": null,
+ "image": "python:3.12-slim",
+ "registry": null,
+ "dataCenterId": null,
+ "env": {"APP_ENV": "test"},
+ "ports": [{"port": 8080, "protocol": "http"}],
+ "egressPolicy": {"mode": "ALLOW_ALL", "allow": []},
+ "idleTimeoutSeconds": 300,
+ "maxLifetimeSeconds": 3600,
+ "lastActivityAt": "2026-10-19T14:02:05Z",
+ "idleExpiresAt": "2026-10-19T14:07:05Z",
+ "expiresAt": "2026-10-19T15:02:05Z",
+ "labels": {"project": "checkout"},
+ "startedAt": null,
+ "terminationReason": null,
+ "terminatedAt": null,
+ "createdAt": "2026-10-19T14:02:05Z",
+ "updatedAt": "2026-10-19T14:02:05Z"
+}
+```
+
+
diff --git a/api-reference-v2/sandboxes/extend-a-sandbox.mdx b/api-reference-v2/sandboxes/extend-a-sandbox.mdx
new file mode 100644
index 000000000..500bbbb4c
--- /dev/null
+++ b/api-reference-v2/sandboxes/extend-a-sandbox.mdx
@@ -0,0 +1,82 @@
+---
+title: "Extend a sandbox's lifetime"
+api: "POST https://api.runpod.io/v2/sandboxes/{id}/extend"
+authMethod: "bearer"
+description: "Extend a running Runpod sandbox's maximum lifetime by setting a new total lifetime measured from creation, within your account's policy limit."
+---
+
+Sets a new maximum lifetime for a running sandbox. `maxLifetimeSeconds` is the new **total** lifetime measured from the sandbox's `createdAt`, the same meaning as on create, so `expiresAt` becomes `createdAt + maxLifetimeSeconds`.
+
+A request beyond your account's maximum lifetime is rejected, and the deadline is left unchanged.
+
+
+
+**Review question:** Does a value above the account's maximum return `400` or `422`? Can `extend` also shorten a sandbox's lifetime? _Remove this box before merging._
+
+
+
+### Path parameters
+
+
+ The sandbox ID.
+
+
+### Body
+
+
+ New total lifetime in seconds, measured from `createdAt`.
+
+
+### Response
+
+Returns the updated sandbox, with the new `maxLifetimeSeconds` and `expiresAt`. See [Get a sandbox](/api-reference-v2/sandboxes/get-a-sandbox) for all fields.
+
+### Errors
+
+| Status | Meaning |
+| --- | --- |
+| `400` | The request is malformed. |
+| `401` | The API key is missing or invalid. |
+| `403` | The API key doesn't have access to sandboxes. |
+| `404` | No sandbox with this ID exists on your account. |
+| `422` | The request can't be applied, for example the value exceeds your account's maximum lifetime or the sandbox is no longer running. |
+| `429` | Too many requests. Retry after a delay. |
+
+
+
+```bash cURL
+curl --request POST \
+ --url https://api.runpod.io/v2/sandboxes/SANDBOX_ID/extend \
+ --header "Authorization: Bearer $RUNPOD_API_KEY" \
+ --header "Content-Type: application/json" \
+ --data '{"maxLifetimeSeconds": 7200}'
+```
+
+```python Python
+import os
+import requests
+
+response = requests.post(
+ "https://api.runpod.io/v2/sandboxes/SANDBOX_ID/extend",
+ headers={"Authorization": f"Bearer {os.environ['RUNPOD_API_KEY']}"},
+ json={"maxLifetimeSeconds": 7200},
+)
+print(response.json()["expiresAt"])
+```
+
+
+
+
+
+```json 200
+{
+ "id": "7h3sbx91kq",
+ "state": "RUNNING",
+ "maxLifetimeSeconds": 7200,
+ "expiresAt": "2026-10-19T16:02:05Z",
+ "createdAt": "2026-10-19T14:02:05Z",
+ "...": "..."
+}
+```
+
+
diff --git a/api-reference-v2/sandboxes/get-a-sandbox.mdx b/api-reference-v2/sandboxes/get-a-sandbox.mdx
new file mode 100644
index 000000000..6568b1e65
--- /dev/null
+++ b/api-reference-v2/sandboxes/get-a-sandbox.mdx
@@ -0,0 +1,167 @@
+---
+title: "Get a sandbox"
+api: "GET https://api.runpod.io/v2/sandboxes/{id}"
+authMethod: "bearer"
+description: "Retrieve a Runpod sandbox by ID with its lifecycle state, compute, cost per hour, timeout deadlines, labels, and termination reason if it has stopped."
+---
+
+Returns a single sandbox by ID. Terminated sandboxes remain readable, with `state`, `terminationReason`, and `terminatedAt` set.
+
+`RUNNING` means the backing compute is allocated, not that the container has finished pulling and started. A sandbox can report `RUNNING` for a short while (typically 10 to 30 seconds) before it accepts commands.
+
+### Path parameters
+
+
+ The sandbox ID.
+
+
+### Response
+
+
+ Unique sandbox identifier.
+
+
+
+ Human-readable name. Not required to be unique.
+
+
+
+ Current lifecycle state: `CREATING`, `RUNNING`, `TERMINATED`, or `FAILED`.
+
+
+
+ Template the sandbox was created from, if any.
+
+
+
+ Container image the sandbox runs, if it was created from an image.
+
+
+
+ CPU flavor backing the sandbox.
+
+
+
+ The compute currently backing the sandbox. Absent while the sandbox is `CREATING` and after it's `TERMINATED`.
+
+
+
+ Number of vCPUs.
+
+
+
+ Memory in GB.
+
+
+
+ Container disk size in GB.
+
+
+
+ Current cost per hour in USD. Billing is per second at this rate.
+
+
+
+
+
+ Seconds of inactivity after which the sandbox is terminated, after any clamping to the account maximum.
+
+
+
+ Maximum total lifetime in seconds, after any clamping to the account maximum.
+
+
+
+ Time of the most recent recorded activity.
+
+
+
+ Time after which the sandbox is terminated for inactivity if no further activity occurs.
+
+
+
+ Time after which the sandbox is terminated for reaching its maximum lifetime.
+
+
+
+ User-supplied string key-value labels.
+
+
+
+ Why the sandbox was terminated. `null` while the sandbox is alive.
+
+
+
+ When the sandbox was terminated. `null` while the sandbox is alive.
+
+
+
+ When the sandbox was created.
+
+
+
+ When the sandbox was last updated.
+
+
+{/* TODO(DOCS-509): List possible terminationReason values once engineering documents them in the spec. */}
+
+### Errors
+
+| Status | Meaning |
+| --- | --- |
+| `401` | The API key is missing or invalid. |
+| `403` | The API key doesn't have access to sandboxes. |
+| `404` | No sandbox with this ID exists on your account. |
+| `429` | Too many requests. Retry after a delay. |
+
+
+
+```bash cURL
+curl --request GET \
+ --url https://api.runpod.io/v2/sandboxes/SANDBOX_ID \
+ --header "Authorization: Bearer $RUNPOD_API_KEY"
+```
+
+```python Python
+import os
+import requests
+
+response = requests.get(
+ "https://api.runpod.io/v2/sandboxes/SANDBOX_ID",
+ headers={"Authorization": f"Bearer {os.environ['RUNPOD_API_KEY']}"},
+)
+print(response.json())
+```
+
+
+
+
+
+```json 200
+{
+ "id": "7h3sbx91kq",
+ "name": "my-sandbox",
+ "state": "RUNNING",
+ "templateId": null,
+ "imageName": "python:3.12-slim",
+ "cpuFlavorId": "cpu3c",
+ "compute": {
+ "vcpuCount": 2,
+ "memoryInGb": 4,
+ "containerDiskInGb": 10,
+ "costPerHr": 0.026
+ },
+ "idleTimeoutSeconds": 300,
+ "maxLifetimeSeconds": 3600,
+ "lastActivityAt": "2026-09-29T14:02:40Z",
+ "idleExpiresAt": "2026-09-29T14:07:40Z",
+ "expiresAt": "2026-09-29T15:02:05Z",
+ "labels": {"project": "checkout"},
+ "terminationReason": null,
+ "terminatedAt": null,
+ "createdAt": "2026-09-29T14:02:05Z",
+ "updatedAt": "2026-09-29T14:02:40Z"
+}
+```
+
+
diff --git a/api-reference-v2/sandboxes/list-sandboxes.mdx b/api-reference-v2/sandboxes/list-sandboxes.mdx
new file mode 100644
index 000000000..c3fe1b7ab
--- /dev/null
+++ b/api-reference-v2/sandboxes/list-sandboxes.mdx
@@ -0,0 +1,94 @@
+---
+title: "List sandboxes"
+api: "GET https://api.runpod.io/v2/sandboxes"
+authMethod: "bearer"
+description: "List the live Runpod sandboxes owned by the authenticated user, filtered by lifecycle state or by labels. Terminated sandboxes are excluded by default."
+---
+
+Returns the authenticated user's live sandboxes. Terminated sandboxes are excluded unless you set `state=TERMINATED`. A terminated sandbox stays readable by ID either way. When you pass several filters, a sandbox must match all of them.
+
+{/* TODO: Add pagination parameters when API-680 / API-682 ship. */}
+
+### Query parameters
+
+
+ Only return sandboxes in this lifecycle state. One of `CREATING`, `RUNNING`, `TERMINATED`, or `FAILED`.
+
+
+
+ Only return sandboxes carrying every one of these labels, each written as `key=value`. Keys and values match exactly, with no wildcard or prefix matching. Repeat the parameter for multiple labels, for example `?labels=project=checkout&labels=env=staging`.
+
+
+### Response
+
+
+ The matching sandboxes. See [Get a sandbox](/api-reference-v2/sandboxes/get-a-sandbox) for the fields of each sandbox.
+
+
+### Errors
+
+| Status | Meaning |
+| --- | --- |
+| `401` | The API key is missing or invalid. |
+| `403` | The API key doesn't have access to sandboxes. |
+| `429` | Too many requests. Retry after a delay. |
+
+
+
+```bash cURL
+curl --request GET \
+ --url "https://api.runpod.io/v2/sandboxes?state=RUNNING&labels=project=quickstart" \
+ --header "Authorization: Bearer $RUNPOD_API_KEY"
+```
+
+```python Python
+import os
+import requests
+
+response = requests.get(
+ "https://api.runpod.io/v2/sandboxes",
+ headers={"Authorization": f"Bearer {os.environ['RUNPOD_API_KEY']}"},
+ params={"state": "RUNNING", "labels": ["project=quickstart"]},
+)
+print(response.json())
+```
+
+
+
+
+
+```json 200
+{
+ "sandboxes": [
+ {
+ "id": "7h3sbx91kq",
+ "name": "quickstart-sandbox",
+ "state": "RUNNING",
+ "templateId": null,
+ "image": "python:3.12-slim",
+ "dataCenterId": "EU-RO-1",
+ "compute": {
+ "vcpuCount": 2,
+ "memoryInGb": 4,
+ "containerDiskInGb": 10,
+ "costPerHr": 0.08
+ },
+ "ports": [],
+ "egressPolicy": {"mode": "ALLOW_ALL", "allow": []},
+ "idleTimeoutSeconds": 300,
+ "maxLifetimeSeconds": 3600,
+ "lastActivityAt": "2026-10-19T14:02:40Z",
+ "idleExpiresAt": "2026-10-19T14:07:40Z",
+ "expiresAt": "2026-10-19T15:02:05Z",
+ "labels": {"project": "quickstart"},
+ "startedAt": "2026-10-19T14:02:09Z",
+ "terminationReason": null,
+ "terminatedAt": null,
+ "createdAt": "2026-10-19T14:02:05Z",
+ "updatedAt": "2026-10-19T14:02:40Z"
+ }
+ ]
+}
+```
+
+
diff --git a/api-reference-v2/sandboxes/run-a-command-in-a-sandbox.mdx b/api-reference-v2/sandboxes/run-a-command-in-a-sandbox.mdx
new file mode 100644
index 000000000..0fcdf523a
--- /dev/null
+++ b/api-reference-v2/sandboxes/run-a-command-in-a-sandbox.mdx
@@ -0,0 +1,131 @@
+---
+title: "Run a command in a sandbox"
+api: "POST https://api.runpod.io/v2/sandboxes/{id}/exec"
+authMethod: "bearer"
+description: "Run a command inside a running Runpod sandbox and get its stdout, stderr, and exit code, or start it in the background for longer work."
+---
+
+Runs one command inside the sandbox and returns its output once it completes.
+
+Commands are synchronous and capped at about 4 seconds. A command still running at the cap is stopped and returns `exitCode: null`. For longer work, set `background: true`: the command starts detached and the request returns as soon as it has started. Redirect its output to a file and read the file with a later command.
+
+A sandbox accepts commands only once its container has started, which can lag the `RUNNING` state by a few seconds. Until then, this endpoint returns `409` with code `sandbox_starting`. Retry after a short delay.
+
+Each accepted command counts as activity and resets the sandbox's idle timer.
+
+
+
+**Review question:** Will exec keep this shape (synchronous, 4-second cap, `background`) for Beta, or will the job-based Commands API (start, poll, stream, cancel) replace it before Beta? If it's replacing it, when will it be in dev? _Remove this box before merging._
+
+
+
+### Path parameters
+
+
+ The sandbox ID.
+
+
+### Body
+
+
+ The command to run, as an argv array with at least one element. It isn't interpreted by a shell. To use shell syntax such as pipes, redirects, or `&&`, wrap it: `["sh", "-c", "..."]`.
+
+
+
+ How long the command may run before it's stopped. The effective limit is currently 4 seconds; longer values are clamped.
+
+
+
+ Start the command detached and return as soon as it has started. `output`, `stdout`, and `stderr` are empty and `exitCode` is `0` once it has started.
+
+
+### Response
+
+
+ Combined stdout and stderr of the command.
+
+
+
+ The command's standard output alone. `null` when the host doesn't report the streams separately.
+
+
+
+ The command's standard error alone. `null` when the host doesn't report the streams separately.
+
+
+
+ The command's exit status, `0` on success. `null` when the command didn't exit on its own, for example when it was stopped at the time limit.
+
+
+
+ The failure reported by the host, if the command exited non-zero or was stopped. `null` when the command succeeded.
+
+
+
+ Wall-clock time the call took, in milliseconds.
+
+
+
+ `true` when the command wrote more than 4 MiB to a stream and the excess was dropped, so the output is incomplete.
+
+
+### Errors
+
+| Status | Meaning |
+| --- | --- |
+| `401` | The API key is missing or invalid. |
+| `403` | The API key doesn't have access to sandboxes. |
+| `404` | No sandbox with this ID exists on your account. |
+| `409` | The sandbox can't run commands right now. The response code says whether to retry: `sandbox_starting` (retry shortly), `container_exited` (create a new sandbox), or `sandbox_terminated` (the sandbox is `TERMINATED` or `FAILED`). |
+| `422` | The request is well-formed but can't be processed. |
+| `429` | Too many requests. Retry after a delay. |
+| `503` | Command execution is temporarily unavailable. The sandbox itself is unaffected. Retry later. |
+
+
+
+```bash cURL
+curl --request POST \
+ --url https://api.runpod.io/v2/sandboxes/SANDBOX_ID/exec \
+ --header "Authorization: Bearer $RUNPOD_API_KEY" \
+ --header "Content-Type: application/json" \
+ --data '{"command": ["sh", "-c", "echo hello && python --version"]}'
+```
+
+```python Python
+import os
+import requests
+
+response = requests.post(
+ "https://api.runpod.io/v2/sandboxes/SANDBOX_ID/exec",
+ headers={"Authorization": f"Bearer {os.environ['RUNPOD_API_KEY']}"},
+ json={"command": ["sh", "-c", "echo hello && python --version"]},
+)
+result = response.json()
+print(result["stdout"], result["exitCode"])
+```
+
+```bash Background
+curl --request POST \
+ --url https://api.runpod.io/v2/sandboxes/SANDBOX_ID/exec \
+ --header "Authorization: Bearer $RUNPOD_API_KEY" \
+ --header "Content-Type: application/json" \
+ --data '{"command": ["sh", "-c", "pip install -r requirements.txt > /root/install.log 2>&1"], "background": true}'
+```
+
+
+
+
+
+```json 200
+{
+ "output": "hello\nPython 3.12.7\n",
+ "stdout": "hello\nPython 3.12.7\n",
+ "stderr": "",
+ "exitCode": 0,
+ "error": null,
+ "durationMs": 412,
+ "truncated": false
+}
+```
+
+
diff --git a/api-reference-v2/sandboxes/stream-sandbox-logs.mdx b/api-reference-v2/sandboxes/stream-sandbox-logs.mdx
new file mode 100644
index 000000000..954ed8266
--- /dev/null
+++ b/api-reference-v2/sandboxes/stream-sandbox-logs.mdx
@@ -0,0 +1,119 @@
+---
+title: "Stream sandbox logs"
+api: "GET https://api.runpod.io/v2/sandboxes/{id}/logs"
+authMethod: "bearer"
+description: "Stream a Runpod sandbox's container and system logs in real time as Server-Sent Events, with backfill and resumable reconnects via Last-Event-ID."
+---
+
+Streams the sandbox's logs as [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). Two log sources are available:
+
+- `container`: stdout of the sandbox's main process.
+- `system`: lifecycle events, such as image pulls and container starts.
+
+Output from [exec](/api-reference-v2/sandboxes/run-a-command-in-a-sandbox) commands doesn't appear in this stream. An open log stream counts as activity, so it keeps the sandbox from timing out as idle.
+
+Each event has an `id` line and a JSON `data` payload:
+
+```text
+id: 2026-10-19T14:02:11.482Z
+data: {"source":"container","line":"...","ts":"2026-10-19T14:02:11.482Z"}
+```
+
+The event `id` is the event's `ts` timestamp, so an `EventSource` client that reconnects resumes where it left off by sending `Last-Event-ID`.
+
+Logs are available only once the sandbox has a backing container, which can lag creation by a short while. Until then, and after the sandbox stops, this endpoint returns `409`.
+
+### Path parameters
+
+
+ The sandbox ID.
+
+
+### Query parameters
+
+
+ Log source to stream: `container` or `system`. Omit to include both.
+
+
+
+ Number of historical lines to backfill before streaming live. Set `0` to stream live with no backfill. Maximum `5000`. Ignored when `since` or `Last-Event-ID` is provided.
+
+
+
+ RFC 3339 timestamp to resume from. When set, `tail` is ignored. Ignored when `Last-Event-ID` is provided.
+
+
+### Headers
+
+
+ Reconnect cursor: an event `id` previously emitted by this endpoint. Takes precedence over `since` and `tail`. `EventSource` sends it automatically on reconnect.
+
+
+### Response
+
+A `text/event-stream` response. Each event's `data` payload has these fields:
+
+
+ The log source: `container` or `system`.
+
+
+
+ The log line.
+
+
+
+ When the line was logged. Also used as the event `id`.
+
+
+### Errors
+
+| Status | Meaning |
+| --- | --- |
+| `401` | The API key is missing or invalid. |
+| `403` | The API key doesn't have access to sandboxes. |
+| `404` | No sandbox with this ID exists on your account. |
+| `409` | The sandbox has no streamable logs right now. The response code says whether to retry: `sandbox_starting` (retry shortly), `container_exited`, or `sandbox_terminated`. |
+| `429` | Too many requests. Retry after a delay. |
+
+
+
+```bash cURL
+curl --no-buffer --request GET \
+ --url "https://api.runpod.io/v2/sandboxes/SANDBOX_ID/logs?source=container&tail=50" \
+ --header "Authorization: Bearer $RUNPOD_API_KEY" \
+ --header "Accept: text/event-stream"
+```
+
+```python Python
+import json
+import os
+import requests
+
+with requests.get(
+ "https://api.runpod.io/v2/sandboxes/SANDBOX_ID/logs",
+ headers={
+ "Authorization": f"Bearer {os.environ['RUNPOD_API_KEY']}",
+ "Accept": "text/event-stream",
+ },
+ params={"tail": 50},
+ stream=True,
+) as response:
+ for line in response.iter_lines(decode_unicode=True):
+ if line and line.startswith("data:"):
+ event = json.loads(line[len("data:"):])
+ print(event["source"], event["line"])
+```
+
+
+
+
+
+```text 200
+id: 2026-10-19T14:02:06.120Z
+data: {"source":"system","line":"Pulling image python:3.12-slim","ts":"2026-10-19T14:02:06.120Z"}
+
+id: 2026-10-19T14:02:11.482Z
+data: {"source":"system","line":"Container started","ts":"2026-10-19T14:02:11.482Z"}
+```
+
+
diff --git a/api-reference-v2/sandboxes/terminate-a-sandbox.mdx b/api-reference-v2/sandboxes/terminate-a-sandbox.mdx
new file mode 100644
index 000000000..79783d7ac
--- /dev/null
+++ b/api-reference-v2/sandboxes/terminate-a-sandbox.mdx
@@ -0,0 +1,60 @@
+---
+title: "Terminate a sandbox"
+api: "DELETE https://api.runpod.io/v2/sandboxes/{id}"
+authMethod: "bearer"
+description: "Terminate a Runpod sandbox to release its compute and stop billing. Termination is final, safe to retry, and the record stays readable by ID."
+---
+
+Terminates a sandbox and releases its compute. Billing stops at termination.
+
+Termination is final: a terminated sandbox can't be restarted. The sandbox record stays readable through [Get a sandbox](/api-reference-v2/sandboxes/get-a-sandbox) afterward, with `terminationReason` and `terminatedAt` set.
+
+Terminating a sandbox that is already terminated succeeds and has no effect, so it's safe to retry this request.
+
+### Path parameters
+
+
+ The sandbox ID.
+
+
+### Response
+
+Returns `204` with no body.
+
+### Errors
+
+| Status | Meaning |
+| --- | --- |
+| `401` | The API key is missing or invalid. |
+| `403` | The API key doesn't have access to sandboxes. |
+| `404` | No sandbox with this ID exists on your account. |
+| `429` | Too many requests. Retry after a delay. |
+
+
+
+```bash cURL
+curl --request DELETE \
+ --url https://api.runpod.io/v2/sandboxes/SANDBOX_ID \
+ --header "Authorization: Bearer $RUNPOD_API_KEY"
+```
+
+```python Python
+import os
+import requests
+
+response = requests.delete(
+ "https://api.runpod.io/v2/sandboxes/SANDBOX_ID",
+ headers={"Authorization": f"Bearer {os.environ['RUNPOD_API_KEY']}"},
+)
+print(response.status_code) # 204
+```
+
+
+
+
+
+```text 204
+No content
+```
+
+
diff --git a/api-reference-v2/sandboxes/update-a-sandbox.mdx b/api-reference-v2/sandboxes/update-a-sandbox.mdx
new file mode 100644
index 000000000..ff129fc9c
--- /dev/null
+++ b/api-reference-v2/sandboxes/update-a-sandbox.mdx
@@ -0,0 +1,84 @@
+---
+title: "Update a sandbox's timeouts"
+api: "PATCH https://api.runpod.io/v2/sandboxes/{id}"
+authMethod: "bearer"
+description: "Change a running Runpod sandbox's idle timeout. The new idle deadline is recalculated from the sandbox's last recorded activity, not from now."
+---
+
+Changes the idle timeout of a running sandbox. The new `idleExpiresAt` is calculated from the sandbox's `lastActivityAt`, not from the time of the request.
+
+Values above your account's maximum are clamped to it, and values under 90 seconds are raised to 90. Read the response for the value applied.
+
+To change the maximum lifetime, use [Extend a sandbox's lifetime](/api-reference-v2/sandboxes/extend-a-sandbox).
+
+
+
+**Review question:** Is `idleTimeoutSeconds` the only field `PATCH` accepts, or can it also change other settings? _Remove this box before merging._
+
+
+
+### Path parameters
+
+
+ The sandbox ID.
+
+
+### Body
+
+
+ New idle timeout in seconds.
+
+
+### Response
+
+Returns the updated sandbox. See [Get a sandbox](/api-reference-v2/sandboxes/get-a-sandbox) for all fields.
+
+### Errors
+
+| Status | Meaning |
+| --- | --- |
+| `400` | The request is malformed. |
+| `401` | The API key is missing or invalid. |
+| `403` | The API key doesn't have access to sandboxes. |
+| `404` | No sandbox with this ID exists on your account. |
+| `422` | The request can't be applied, for example the sandbox is no longer running. |
+| `429` | Too many requests. Retry after a delay. |
+
+
+
+```bash cURL
+curl --request PATCH \
+ --url https://api.runpod.io/v2/sandboxes/SANDBOX_ID \
+ --header "Authorization: Bearer $RUNPOD_API_KEY" \
+ --header "Content-Type: application/json" \
+ --data '{"idleTimeoutSeconds": 1800}'
+```
+
+```python Python
+import os
+import requests
+
+response = requests.patch(
+ "https://api.runpod.io/v2/sandboxes/SANDBOX_ID",
+ headers={"Authorization": f"Bearer {os.environ['RUNPOD_API_KEY']}"},
+ json={"idleTimeoutSeconds": 1800},
+)
+print(response.json()["idleExpiresAt"])
+```
+
+
+
+
+
+```json 200
+{
+ "id": "7h3sbx91kq",
+ "state": "RUNNING",
+ "idleTimeoutSeconds": 1800,
+ "lastActivityAt": "2026-10-19T14:02:40Z",
+ "idleExpiresAt": "2026-10-19T14:32:40Z",
+ "...": "..."
+}
+```
+
+
diff --git a/docs.json b/docs.json
index 7d76631cf..29bd357eb 100644
--- a/docs.json
+++ b/docs.json
@@ -313,6 +313,13 @@
}
]
},
+ {
+ "group": "Sandboxes",
+ "pages": [
+ "sandboxes/overview",
+ "sandboxes/quickstart"
+ ]
+ },
{
"group": "Accounts and billing",
"pages": [
@@ -638,6 +645,19 @@
"api-reference-v2/clusters/list-a-clusters-pods"
]
},
+ {
+ "group": "Sandboxes",
+ "pages": [
+ "api-reference-v2/sandboxes/list-sandboxes",
+ "api-reference-v2/sandboxes/create-a-sandbox",
+ "api-reference-v2/sandboxes/get-a-sandbox",
+ "api-reference-v2/sandboxes/terminate-a-sandbox",
+ "api-reference-v2/sandboxes/update-a-sandbox",
+ "api-reference-v2/sandboxes/extend-a-sandbox",
+ "api-reference-v2/sandboxes/run-a-command-in-a-sandbox",
+ "api-reference-v2/sandboxes/stream-sandbox-logs"
+ ]
+ },
{
"group": "Serverless",
"pages": [
diff --git a/sandboxes/overview.mdx b/sandboxes/overview.mdx
new file mode 100644
index 000000000..03ab391b2
--- /dev/null
+++ b/sandboxes/overview.mdx
@@ -0,0 +1,199 @@
+---
+title: "Sandboxes"
+sidebarTitle: "Overview"
+description: "Create short-lived, isolated CPU environments on demand to run untrusted or agent-generated code, then tear them down. Billed per second."
+---
+
+Sandboxes are short-lived, isolated CPU environments that you create on demand, run commands in, and tear down when you're done. They're built for running untrusted code: code generated by an AI agent, code submitted by your users, or one-off scripts you don't want touching your own infrastructure.
+
+You manage sandboxes through the [REST API v2](/api-reference-v2/overview). Each sandbox runs a container image you choose (or a default image), starts in seconds, and is billed per second until it terminates.
+
+
+
+ Create a sandbox, run a command, and terminate it.
+
+
+
+ Endpoints and fields for the `/v2/sandboxes` resource.
+
+
+
+## When to use a sandbox
+
+Use a sandbox when you need a clean, disposable environment for a short task:
+
+- An AI agent needs to execute the code it writes.
+- Your application runs code or commands supplied by end users.
+- You want to test a script or package in isolation before running it elsewhere.
+
+For long-running workloads, GPU compute, or persistent development environments, use [Pods](/pods/overview) instead. For request-driven inference that scales with traffic, use [Serverless](/serverless/overview).
+
+## Isolation
+
+Every sandbox runs under [gVisor](https://gvisor.dev/), a user-space kernel that intercepts the container's system calls. Code inside a sandbox can't reach the host machine or other tenants' sandboxes, so you can run code you don't trust.
+
+## Images and templates
+
+You can start a sandbox in three ways:
+
+- **Default image:** send an empty request body (`{}`) and the sandbox runs Runpod's default sandbox image.
+- **Container image:** set `image` to any public image, for example `python:3.12-slim`. For a private image, also set `registry` to the ID of one of your saved container registry credentials (from `/v2/registries`).
+- **Template:** set `templateId` to a sandbox template. Runpod provides curated templates, such as Ubuntu and Harbor.
+
+`image` and `templateId` can't be used together.
+
+
+
+**Review question:** Which templates will be available to customers at Beta, and should the docs list their IDs? _Remove this box before merging._
+
+
+
+If you create a sandbox from an image without a start command, the container runs a keepalive process so it stays up for you to run commands in. To run your own main process, set `cmd` (and optionally `entrypoint`) in exec form, for example `["python", "-m", "http.server", "8080"]`.
+
+## Lifecycle
+
+A sandbox moves through these states:
+
+| State | Meaning |
+| --- | --- |
+| `CREATING` | Compute is being allocated and the container image is being prepared. |
+| `RUNNING` | Compute is allocated. The container may still be starting (see [Known limitations](#known-limitations)). |
+| `TERMINATED` | The sandbox has stopped and its compute is released. Billing has stopped. |
+| `FAILED` | The sandbox couldn't be created. |
+
+`POST /v2/sandboxes` returns `201` with the new sandbox, usually still in the `CREATING` state. Poll `GET /v2/sandboxes/{id}` until `state` is `RUNNING` before you send commands.
+
+### Retry creates safely
+
+Send an `Idempotency-Key` header with each create request. If the response is lost and you retry with the same key and the same body, the API returns the sandbox the first request created instead of creating (and billing for) a second one.
+
+If the first create failed, for example because no capacity was available, that key is spent: retrying with it returns `409`. Retry a failed create with a new key.
+
+### Termination is final
+
+A terminated sandbox can't be restarted. Its record stays readable through `GET /v2/sandboxes/{id}`, with `terminationReason` and `terminatedAt` set, so you can see why and when it stopped. To run more work, create a new sandbox.
+
+Terminating a sandbox that is already terminated succeeds, so it's safe to retry `DELETE /v2/sandboxes/{id}`.
+
+## Timeouts
+
+Every sandbox has two clocks. Whichever runs out first terminates the sandbox and stops billing:
+
+- **Idle timeout** (`idleTimeoutSeconds`): how long the sandbox can go without activity. Running a command and keeping a log stream open both count as activity. The deadline is reported in `idleExpiresAt`.
+- **Maximum lifetime** (`maxLifetimeSeconds`): a hard ceiling on total lifetime, counted from creation. The deadline is reported in `expiresAt`.
+
+
+
+**Review question:** Are these the production defaults and maximums (idle 10 min / max 2 h, lifetime 1 h / max 24 h)? _Remove this box before merging._
+
+
+
+| Setting | Default | Minimum | Maximum |
+| --- | --- | --- | --- |
+| Idle timeout | 10 minutes | 90 seconds | 2 hours |
+| Maximum lifetime | 1 hour | | 24 hours |
+
+If you omit a value, the default applies. Values above the maximum are clamped to it, and idle timeouts under 90 seconds are raised to 90. Check `idleTimeoutSeconds` and `maxLifetimeSeconds` in the response to see the values actually applied.
+
+You can change both while a sandbox is running:
+
+- [Extend a sandbox's lifetime](/api-reference-v2/sandboxes/extend-a-sandbox) with `POST /v2/sandboxes/{id}/extend`.
+- [Change its idle timeout](/api-reference-v2/sandboxes/update-a-sandbox) with `PATCH /v2/sandboxes/{id}`.
+
+
+
+Terminate sandboxes explicitly when you're finished, including in your error-handling paths. Don't rely on a timeout to stop billing.
+
+
+
+## Network access
+
+By default, a sandbox has **open outbound internet access**. Code inside it can reach any host on the internet, for example to install packages or call external APIs.
+
+Restricting outbound traffic is opt-in and is not yet available to all accounts. When it is, you'll be able to set `egressMode` on create to `DENY_ALL` (no outbound access) or `ALLOW_LIST` (only the hostnames in `egressAllowlist`). Until restriction is enabled for your account, any mode other than `ALLOW_ALL` returns `400`.
+
+
+
+**Review question:** Will egress restriction (`DENY_ALL`, `ALLOW_LIST`) be enforced and available to customers by Beta, or should this stay "coming soon"? _Remove this box before merging._
+
+
+
+## Exposed ports
+
+To reach a service running inside a sandbox, list its ports in `ports` when you create the sandbox (up to 15):
+
+```json
+{"ports": [{"port": 8080, "protocol": "http"}]}
+```
+
+- **`http`** ports are reached through the Runpod proxy at the `url` returned in the sandbox's `ports` list. Treat the URL as opaque.
+- **`tcp`** and **`udp`** ports are reached directly at the returned `ip` and `publicPort`.
+
+## Storage
+
+Each sandbox has an ephemeral container disk, 10 GB by default (set `disk` to change it). It's deleted when the sandbox terminates.
+
+To keep data, mount an existing volume with `mounts`:
+
+- `mounts.network`: a [network volume](/storage/network-volumes), mounted at `/workspace` by default. Place the sandbox in the volume's data center.
+- `mounts.global`: a global volume, mounted at `/workspace-global` by default.
+
+
+
+**Review question:** Will the default user be able to write to network volumes in EU-RO-1 (MooseFS, AI-262) by Beta? If not, should network volumes be left out of the docs? _Remove this box before merging._
+
+
+
+## Connect over SSH
+
+Set `startSsh: true` on create to inject the SSH public keys registered on your account. Connect using the details in the sandbox's `ssh` field. For a direct connection, also expose `22/tcp` in `ports`. Only images that start `sshd` from the `PUBLIC_KEY` environment variable support this; all Runpod official images do.
+
+## Environment variables and secrets
+
+Pass environment variables in `env`. You can reference a [Runpod secret](/pods/templates/secrets) with `{{ RUNPOD_SECRET_name }}`; a create that references a secret you can't access is rejected.
+
+
+
+`env` is returned as written in API responses, so anyone who can read the sandbox can see plain values. Use secret references for credentials instead of plain values.
+
+
+
+## Compute and pricing
+
+Sandboxes run on CPU compute. Set the CPU flavor and vCPU count in `cpu`, or omit it to use the standard sandbox configuration. Memory and container disk limits scale with the flavor. The `cpu` and `compute` fields in the sandbox response show what's backing it.
+
+Sandboxes are billed per second from creation until termination, at the rate shown in `compute.costPerHr` (USD per hour). Your account needs enough balance to start a sandbox; otherwise, create returns `402`.
+
+
+
+**Review question:** Should the docs publish the per-unit rates ($0.035 per vCPU-hour, $0.0175 per GB-hour) at Beta? Will there be a launch credit? Also: which data centers offer sandboxes at Beta? _Remove this box before merging._
+
+
+
+
+
+**Review question:** Will sandbox concurrency be a separate cap or part of the shared per-plan vCPU pool (64 / 256 / 512)? What numbers and plan names should the docs show? _Remove this box before merging._
+
+
+
+## Labels
+
+Attach `labels` (string key-value pairs) when you create a sandbox to track which job, user, or environment it belongs to. You can filter `GET /v2/sandboxes` by one or more labels, written as `key=value`.
+
+## Known limitations
+
+These limitations reflect current behavior and will change as the feature matures:
+
+- **The first command can return `409`.** `RUNNING` means compute is allocated, not that the container has finished starting. Until it has, commands and log streams return `409` with code `sandbox_starting`. Retry after a short delay.
+- **Commands are capped at about 4 seconds.** A command still running at the cap is stopped and returns `exitCode: null`. For longer work, run the command with `background: true`, write its output to a file, and read the file with a later command.
+- **A sandbox can show `RUNNING` after its container exits.** If a command returns `409` with code `container_exited`, terminate the sandbox and create a new one.
+- **`/tmp` uses memory.** Writing large files to `/tmp` counts against the sandbox's memory and can stop it. Write large files elsewhere, such as your home directory or a mounted volume.
+- **Some terminal UIs don't render.** Tool compatibility follows the gVisor version Runpod runs, and some full-screen terminal apps may not display correctly.
+
+{/* TODO: Rewrite the command limitations when streaming/async exec ships. sudo is not yet available in prod; don't document it until validated. */}
+
+## Next steps
+
+- [Create your first sandbox](/sandboxes/quickstart).
+- Browse the [Sandboxes API reference](/api-reference-v2/sandboxes/create-a-sandbox).
+- Learn how to [create an API key](/get-started/credentials).
diff --git a/sandboxes/quickstart.mdx b/sandboxes/quickstart.mdx
new file mode 100644
index 000000000..e065365e1
--- /dev/null
+++ b/sandboxes/quickstart.mdx
@@ -0,0 +1,244 @@
+---
+title: "Sandboxes quickstart"
+sidebarTitle: "Quickstart"
+description: "Create a Runpod sandbox with the REST API, run a shell command inside it, stream its logs, and terminate it when you're done, in a few minutes."
+---
+
+Learn how to create a sandbox, run commands in it, and clean it up using the Runpod REST API v2.
+
+## What you'll learn
+
+- How to create a sandbox from a container image.
+- How to wait until the sandbox is ready to accept commands.
+- How to run a command and read its output.
+- How to stream the sandbox's logs.
+- How to terminate the sandbox so billing stops.
+
+## Requirements
+
+- A Runpod account with a positive balance.
+- A [Runpod API key](/get-started/credentials).
+- `curl` and [`jq`](https://jqlang.org/) for the command-line examples, or Python 3 with the `requests` package for the Python examples.
+
+Set your API key as an environment variable so the examples can use it:
+
+```bash
+export RUNPOD_API_KEY="YOUR_API_KEY"
+```
+
+## Step 1: Create a sandbox
+
+Create a sandbox from the `python:3.12-slim` image with a five-minute idle timeout. The `Idempotency-Key` header makes the request safe to retry if the response is lost:
+
+
+
+```bash cURL
+curl --request POST \
+ --url https://api.runpod.io/v2/sandboxes \
+ --header "Authorization: Bearer $RUNPOD_API_KEY" \
+ --header "Content-Type: application/json" \
+ --header "Idempotency-Key: quickstart-$(date +%s)" \
+ --data '{
+ "name": "quickstart-sandbox",
+ "image": "python:3.12-slim",
+ "idleTimeoutSeconds": 300,
+ "labels": {"project": "quickstart"}
+ }'
+```
+
+```python Python
+import os
+import uuid
+import requests
+
+BASE_URL = "https://api.runpod.io/v2"
+HEADERS = {"Authorization": f"Bearer {os.environ['RUNPOD_API_KEY']}"}
+
+response = requests.post(
+ f"{BASE_URL}/sandboxes",
+ headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
+ json={
+ "name": "quickstart-sandbox",
+ "image": "python:3.12-slim",
+ "idleTimeoutSeconds": 300,
+ "labels": {"project": "quickstart"},
+ },
+)
+response.raise_for_status()
+sandbox = response.json()
+sandbox_id = sandbox["id"]
+print(sandbox_id, sandbox["state"])
+```
+
+
+
+The response is the new sandbox. Note its `id`, which you use in every later request:
+
+```json
+{
+ "id": "7h3sbx91kq",
+ "name": "quickstart-sandbox",
+ "state": "CREATING",
+ "image": "python:3.12-slim",
+ "idleTimeoutSeconds": 300,
+ "maxLifetimeSeconds": 3600,
+ "egressPolicy": {"mode": "ALLOW_ALL", "allow": []},
+ "labels": {"project": "quickstart"},
+ "...": "..."
+}
+```
+
+Every field is optional; an empty body (`{}`) creates a sandbox from the default image. See [Create a sandbox](/api-reference-v2/sandboxes/create-a-sandbox) for all fields.
+
+
+
+By default, the sandbox has open outbound internet access. See [Network access](/sandboxes/overview#network-access).
+
+
+
+## Step 2: Wait for the sandbox to be ready
+
+Poll the sandbox until its `state` is `RUNNING`:
+
+
+
+```bash cURL
+export SANDBOX_ID="7h3sbx91kq"
+
+curl --request GET \
+ --url https://api.runpod.io/v2/sandboxes/$SANDBOX_ID \
+ --header "Authorization: Bearer $RUNPOD_API_KEY" | jq -r .state
+```
+
+```python Python
+import time
+
+while True:
+ sandbox = requests.get(f"{BASE_URL}/sandboxes/{sandbox_id}", headers=HEADERS).json()
+ if sandbox["state"] == "RUNNING":
+ break
+ if sandbox["state"] in ("FAILED", "TERMINATED"):
+ raise RuntimeError(f"Sandbox stopped: {sandbox.get('terminationReason')}")
+ time.sleep(2)
+```
+
+
+
+If the state becomes `FAILED`, check `terminationReason` in the response.
+
+## Step 3: Run a command
+
+Send a command to the sandbox as an argv array. Commands aren't run through a shell, so wrap them in `["sh", "-c", "..."]` when you need shell syntax such as pipes or `&&`:
+
+
+
+```bash cURL
+curl --request POST \
+ --url https://api.runpod.io/v2/sandboxes/$SANDBOX_ID/exec \
+ --header "Authorization: Bearer $RUNPOD_API_KEY" \
+ --header "Content-Type: application/json" \
+ --data '{"command": ["sh", "-c", "echo hello && python --version"]}'
+```
+
+```python Python
+def run(command, retries=15):
+ # The container can take a few seconds to start after RUNNING.
+ # Retry only while the API says it's still starting.
+ for _ in range(retries):
+ response = requests.post(
+ f"{BASE_URL}/sandboxes/{sandbox_id}/exec",
+ headers=HEADERS,
+ json={"command": command},
+ )
+ if response.status_code == 409 and response.json().get("code") == "sandbox_starting":
+ time.sleep(2)
+ continue
+ response.raise_for_status()
+ return response.json()
+ raise TimeoutError("Sandbox did not accept commands in time")
+
+result = run(["sh", "-c", "echo hello && python --version"])
+print(result["stdout"], result["exitCode"])
+```
+
+
+
+The response contains the command's output and exit status:
+
+```json
+{
+ "output": "hello\nPython 3.12.7\n",
+ "stdout": "hello\nPython 3.12.7\n",
+ "stderr": "",
+ "exitCode": 0,
+ "error": null,
+ "durationMs": 412,
+ "truncated": false
+}
+```
+
+`exitCode` is `0` when the command succeeds. `output` always contains stdout and stderr combined.
+
+
+
+Commands are capped at about 4 seconds; a command still running at the cap is stopped and returns `exitCode: null`. For longer work, add `"background": true` to the request and redirect the command's output to a file, then read that file with a later command.
+
+
+
+
+
+**Review question:** What does the `409` response body look like? These examples assume the conflict code is in a field named `code`. Also, are `python-requests` and `curl` user agents allowed through Cloudflare in prod? _Remove this box before merging._
+
+
+
+## Step 4: Stream logs
+
+Stream the sandbox's logs as Server-Sent Events. The stream includes the main process's stdout (`container`) and lifecycle events such as image pulls (`system`). Output from commands you run with exec doesn't appear here. An open log stream counts as activity, so it keeps the sandbox from timing out as idle.
+
+```bash
+curl --no-buffer --request GET \
+ --url "https://api.runpod.io/v2/sandboxes/$SANDBOX_ID/logs?tail=50" \
+ --header "Authorization: Bearer $RUNPOD_API_KEY" \
+ --header "Accept: text/event-stream"
+```
+
+Each event carries a JSON payload:
+
+```text
+id: 2026-09-29T14:02:11.482Z
+data: {"source":"system","line":"Pulling image python:3.12-slim","ts":"2026-09-29T14:02:11.482Z"}
+```
+
+Press `Ctrl+C` to stop streaming.
+
+## Step 5: Terminate the sandbox
+
+When you're done, terminate the sandbox to stop billing:
+
+
+
+```bash cURL
+curl --request DELETE \
+ --url https://api.runpod.io/v2/sandboxes/$SANDBOX_ID \
+ --header "Authorization: Bearer $RUNPOD_API_KEY"
+```
+
+```python Python
+requests.delete(f"{BASE_URL}/sandboxes/{sandbox_id}", headers=HEADERS).raise_for_status()
+```
+
+
+
+A successful request returns `204` with no body. Terminating a sandbox is final: you can still read it with `GET`, but you can't restart it.
+
+
+
+In your own code, terminate the sandbox in a `finally` block or equivalent, so it's cleaned up even when a command fails. If you forget, the sandbox terminates at its idle timeout or maximum lifetime, whichever comes first.
+
+
+
+## Next steps
+
+- Learn about [timeouts, ports, storage, and network access](/sandboxes/overview).
+- [Extend a running sandbox's lifetime](/api-reference-v2/sandboxes/extend-a-sandbox).
+- Browse the full [Sandboxes API reference](/api-reference-v2/sandboxes/create-a-sandbox).