> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stateset.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandbox webhooks

> Twenty-four events the sandbox controller emits, how to subscribe, and how to verify the signature on each delivery.

Webhooks push sandbox events to your server as they happen, so an agent orchestrator does not poll
`GET /sandbox/{id}/status` in a loop waiting for a pod to come up or a command to finish.

## Events

**Sandbox lifecycle**

| Event             | Fires when                       | Payload carries              |
| ----------------- | -------------------------------- | ---------------------------- |
| `sandbox.created` | The pod has been created         | `sandbox_id`, `org_id`       |
| `sandbox.ready`   | The sandbox accepts commands     | `sandbox_id`, startup timing |
| `sandbox.stopped` | The sandbox terminated           | `sandbox_id`, reason         |
| `sandbox.error`   | An error occurred in the sandbox | `sandbox_id`, error details  |
| `sandbox.timeout` | The timeout limit was reached    | `sandbox_id`, duration       |

**Command execution**

| Event               | Fires when                      | Payload carries                   |
| ------------------- | ------------------------------- | --------------------------------- |
| `command.started`   | Execution began                 | `sandbox_id`, command             |
| `command.completed` | The command exited successfully | `sandbox_id`, `exit_code`, output |
| `command.failed`    | The command failed              | `sandbox_id`, `exit_code`, error  |

**Files and artifacts**

| Event               | Fires when                          | Payload carries          |
| ------------------- | ----------------------------------- | ------------------------ |
| `file.written`      | A file was written into the sandbox | `sandbox_id`, path, size |
| `artifact.uploaded` | An artifact reached storage         | `sandbox_id`, path, url  |
| `artifact.deleted`  | An artifact was deleted             | `sandbox_id`, path       |

**Checkpoints**

| Event                 | Fires when                        | Payload carries               |
| --------------------- | --------------------------------- | ----------------------------- |
| `checkpoint.created`  | A checkpoint was saved            | `sandbox_id`, `checkpoint_id` |
| `checkpoint.restored` | A checkpoint was restored         | `sandbox_id`, `checkpoint_id` |
| `checkpoint.cloned`   | A checkpoint became a new sandbox | `source_id`, `new_sandbox_id` |
| `checkpoint.deleted`  | A checkpoint was deleted          | `checkpoint_id`               |

**Resources and security**

| Event                         | Fires when                                          | Payload carries               |
| ----------------------------- | --------------------------------------------------- | ----------------------------- |
| `resource.warning`            | Usage crossed 80%                                   | `sandbox_id`, resource, value |
| `resource.critical`           | Usage crossed 95%                                   | `sandbox_id`, resource, value |
| `mcp.started` / `mcp.stopped` | An MCP server inside the sandbox started or stopped | `sandbox_id`, `server_name`   |
| `security.alert`              | A security event was detected                       | `sandbox_id`, `alert_type`    |
| `security.suspended`          | The organization was suspended                      | `org_id`, reason              |

## Subscribe

Subscribe to specific events, or to `"*"` for all of them. Give a `secret` — it is what lets you
verify that a delivery came from StateSet rather than from anyone who learned your URL.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.sandbox.stateset.app/api/webhooks \
    -H "Authorization: ApiKey sk_your_api_key" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://your-app.com/webhooks/sandbox",
      "events": ["sandbox.ready", "command.completed", "command.failed", "sandbox.stopped"],
      "secret": "whsec_your_webhook_secret",
      "retry_count": 3,
      "retry_delay_ms": 1000,
      "timeout_ms": 30000
    }'
  ```

  ```typescript Node.js theme={null}
  import { StateSetSandbox } from '@stateset/sandbox-sdk';

  const sdk = new StateSetSandbox({ apiKey: 'sk_your_api_key' });

  const webhook = await sdk.webhooks.create({
    url: 'https://your-app.com/webhooks/sandbox',
    events: ['sandbox.ready', 'command.completed', 'command.failed', 'sandbox.stopped'],
    secret: 'whsec_your_webhook_secret',
    retry_count: 3,
  });
  ```

  ```python Python theme={null}
  from stateset_sandbox import StateSetSandbox

  sdk = StateSetSandbox(api_key="sk_your_api_key")

  webhook = sdk.webhooks.create(
      url="https://your-app.com/webhooks/sandbox",
      events=["sandbox.ready", "command.completed", "command.failed", "sandbox.stopped"],
      secret="whsec_your_webhook_secret",
      retry_count=3,
  )
  ```
</CodeGroup>

| Field            | Type               | Default  |                                            |
| ---------------- | ------------------ | -------- | ------------------------------------------ |
| `url`            | string             | required | HTTPS endpoint that receives deliveries    |
| `events`         | string\[] or `"*"` | `"*"`    | Which events to receive                    |
| `secret`         | string             | —        | Signs each delivery; see below             |
| `headers`        | object             | —        | Extra headers to include on every delivery |
| `retry_count`    | number             | `3`      | Retries on a non-2xx or timeout, 0–10      |
| `retry_delay_ms` | number             | `1000`   | Base delay between retries                 |
| `timeout_ms`     | number             | `30000`  | Delivery timeout, 1000–60000               |

## The payload

Every delivery has the same envelope; `data` is event-specific.

```json theme={null}
{
  "id": "pay_lx7k8j_abc123",
  "event": "sandbox.ready",
  "timestamp": "2026-08-30T12:00:00.000Z",
  "sandboxId": "sb-abc123",
  "orgId": "org-xyz",
  "data": {
    "image": "stateset/sandbox:latest",
    "cpus": "2",
    "memory": "2Gi",
    "timeout_seconds": 600
  }
}
```

`id` is unique per delivery and stable across retries — use it to make your handler idempotent,
because a retry after a timeout means you may see the same delivery twice.

## Verify the signature

When a subscription has a `secret`, every delivery carries an HMAC-SHA256 of the raw body in the
`X-Webhook-Signature` header, as `sha256=<hex>`. Verify it against the **raw request body**, not a
re-serialised object — whitespace differences change the digest.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verify(rawBody, signature, secret) {
    const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
    return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  }

  app.post('/webhooks/sandbox', express.raw({ type: 'application/json' }), (req, res) => {
    if (!verify(req.body, req.headers['x-webhook-signature'] || '', process.env.WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    const event = JSON.parse(req.body);
    res.status(200).json({ received: true });
  });
  ```

  ```python Python theme={null}
  import hmac, hashlib

  def verify(raw_body: bytes, signature: str, secret: str) -> bool:
      expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(signature, expected)

  @app.route("/webhooks/sandbox", methods=["POST"])
  def webhook():
      if not verify(request.data, request.headers.get("X-Webhook-Signature", ""), WEBHOOK_SECRET):
          return {"error": "Invalid signature"}, 401
      return {"received": True}, 200
  ```

  ```go Go theme={null}
  func verify(payload []byte, signature, secret string) bool {
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write(payload)
      expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
      return hmac.Equal([]byte(signature), []byte(expected))
  }
  ```
</CodeGroup>

<Warning>
  Compare with a constant-time function — `timingSafeEqual`, `hmac.compare_digest`, `hmac.Equal` —
  not `==`. A plain string comparison leaks how many leading bytes matched.
</Warning>

## Related

* [Sandbox API reference](/stateset-sandbox/stateset-sandbox-api-reference) — the status endpoints webhooks let you stop polling
* [Sandbox security guide](/stateset-sandbox/stateset-sandbox-security-guide) — secrets, isolation and audit logging
* [Sandbox operations](/stateset-sandbox/stateset-sandbox-operations)
