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

# Webhooks

> Receive signed notifications when a sandbox becomes ready, finishes restoring, errors, or archives.

<video controls playsInline preload="metadata" poster="/videos/webhooks.jpg" src="https://mintcdn.com/ariana-1e97e6c3/KkeC9MhSefNUGJb6/videos/webhooks.mp4?fit=max&auto=format&n=KkeC9MhSefNUGJb6&q=85&s=a4a01c48f5e2afa7845199e011ffcc8f" className="w-full aspect-video rounded-xl" data-path="videos/webhooks.mp4" />

Boat webhooks send account-wide lifecycle events to your HTTPS endpoint. Use them to start work when a sandbox is usable, respond to provisioning failures, or continue an automation after a sandbox stops.

| | CLI | API | SDKs | Dashboard |
| - | - | - | - | - |
| Register | `boat webhook create` | `POST /webhooks` | `createWebhook` | [Webhooks](https://boat.dev/dashboard?tab=webhooks) |
| List / read | `boat webhook list` | `GET /webhooks`, `GET /webhooks/{id}` | `listWebhooks`, `getWebhook` | [Webhooks](https://boat.dev/dashboard?tab=webhooks) |
| Edit in place | not available | `PATCH /webhooks/{id}` | `updateWebhook` | not available |
| Rotate secret | `boat webhook rotate` | `POST /webhooks/{id}/rotate` | `rotateWebhookSigningSecret` | [Webhooks](https://boat.dev/dashboard?tab=webhooks) |
| Remove | `boat webhook remove` | `DELETE /webhooks/{id}` | `deleteWebhook` | [Webhooks](https://boat.dev/dashboard?tab=webhooks) |
| Delivery history | not available | not available | not available | [Webhooks](https://boat.dev/dashboard?tab=webhooks) → Deliveries |

## Register an endpoint

<CodeGroup>
  ```bash CLI theme={null}
  boat webhook create https://example.com/hooks/sandbox \
    --name production \
    --event ready \
    --event error \
    --event archived
  ```

  ```bash curl theme={null}
  curl -sS -X POST "$BOAT_API_BASE/webhooks" \
    -H "Authorization: Bearer $BOAT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "production",
      "url": "https://example.com/hooks/sandbox",
      "events": ["sandbox.ready", "sandbox.error", "sandbox.archived"]
    }'
  ```

  ```ts TypeScript theme={null}
  const created = await sandbox.createWebhook({
    name: "production",
    url: "https://example.com/hooks/sandbox",
    events: new Set(["sandbox.ready", "sandbox.error", "sandbox.archived"]),
  });
  console.log(created.webhook.id, created.secret);   // secret is shown once
  ```

  ```python Python theme={null}
  from boat_sdk.models.webhook_create_request import WebhookCreateRequest

  created = sandbox.create_webhook(WebhookCreateRequest(
      name="production",
      url="https://example.com/hooks/sandbox",
      events=["sandbox.ready", "sandbox.error", "sandbox.archived"],
  ))
  print(created.webhook.id, created.secret)   # secret is shown once
  ```
</CodeGroup>

The CLI's `--event` accepts `ready`, `error`, `archived`, or `hydrated` with or without the `sandbox.` prefix, and subscribes to all four when you omit it. The API and SDKs take the full `sandbox.` form and **require** at least one event.

The call returns a signing secret beginning with `whsec_`. **It is shown only once.** Store it in your secret manager.

<Note>
  Endpoints must use HTTPS on port 443 and resolve only to public IP addresses. Redirects are not followed. An account can register up to 10 endpoint URLs.
</Note>

## Manage endpoints

<CodeGroup>
  ```bash CLI theme={null}
  boat webhook list
  boat webhook rotate <id>   # replaces the signing secret
  boat webhook remove <id>
  ```

  ```bash curl theme={null}
  curl -sS "$BOAT_API_BASE/webhooks" \
    -H "Authorization: Bearer $BOAT_API_KEY"

  curl -sS "$BOAT_API_BASE/webhooks/$WEBHOOK_ID" \
    -H "Authorization: Bearer $BOAT_API_KEY"

  curl -sS -X PATCH "$BOAT_API_BASE/webhooks/$WEBHOOK_ID" \
    -H "Authorization: Bearer $BOAT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"events":["sandbox.error"]}'

  curl -sS -X POST "$BOAT_API_BASE/webhooks/$WEBHOOK_ID/rotate" \
    -H "Authorization: Bearer $BOAT_API_KEY"

  curl -sS -X DELETE "$BOAT_API_BASE/webhooks/$WEBHOOK_ID" \
    -H "Authorization: Bearer $BOAT_API_KEY"
  ```

  ```ts TypeScript theme={null}
  await sandbox.listWebhooks();
  await sandbox.getWebhook({ webhookId: "wh_0123456789abcdef01234567" });

  await sandbox.updateWebhook({
    webhookId: "wh_0123456789abcdef01234567",
    events: new Set(["sandbox.error"]),
  });

  const rotated = await sandbox.rotateWebhookSigningSecret({ webhookId: "wh_0123456789abcdef01234567" });
  console.log(rotated.secret);

  await sandbox.deleteWebhook({ webhookId: "wh_0123456789abcdef01234567" });
  ```

  ```python Python theme={null}
  from boat_sdk.models.webhook_update_request import WebhookUpdateRequest

  sandbox.list_webhooks()
  sandbox.get_webhook("wh_0123456789abcdef01234567")

  sandbox.update_webhook(
      "wh_0123456789abcdef01234567",
      WebhookUpdateRequest(events=["sandbox.error"]),
  )

  rotated = sandbox.rotate_webhook_signing_secret("wh_0123456789abcdef01234567")
  print(rotated.secret)

  sandbox.delete_webhook("wh_0123456789abcdef01234567")
  ```
</CodeGroup>

The [Webhooks](https://boat.dev/dashboard?tab=webhooks) tab of the dashboard does the same, and is the only surface that shows delivery history.

<Note>
  Only the API and SDKs can change an existing endpoint's name, URL, or events in place (`PATCH /webhooks/{webhookId}`). The CLI and the dashboard have no edit: delete and re-create, which mints a new signing secret.
</Note>

During rotation, accept both the old and new secret briefly: an attempt already in flight can still carry the old signature. Deleting an endpoint removes queued deliveries, but an attempt already in flight can still arrive.

## Events

| Event | Sent when |
| - | - |
| `sandbox.ready` | A Sandbox enters a usable state (`ready`, `idle`, or `running`) after being non-usable, including recovery from an error. |
| `sandbox.hydrated` | A restored sandbox finishes materializing. On an eager restore this is the same moment as `sandbox.ready`. On a lazy restore it arrives later, once the background disk copy is final and the docker volumes are on the real disk. The sandbox's docker containers start before that, as soon as their images are loaded; when the volumes reach the real disk, each running container's volume mounts move onto it in place, with a pause of about 0.1 s and no restart. |
| `sandbox.error` | A Sandbox enters `error`. |
| `sandbox.archived` | A Sandbox finishes stopping and enters `archived`. |
| `sandbox.degraded` | The machine is alive but Boat cannot fully reach it, about a minute after it stops answering. The sandbox keeps running and is not stopped for this. `data.reason` says why. |
| `sandbox.recovered` | A degraded sandbox is fully reachable again. |

`sandbox.ready`, `sandbox.error`, and `sandbox.archived` are created in the same database transaction as the state change. `sandbox.hydrated` fires when the restored disk is final, which can be the same moment as `sandbox.ready` or minutes later. To poll instead, read `hydrated` and `hydratedAt` on `GET /sandboxes/{id}`.

A degraded sandbox shows `"health": "degraded"` and a `healthReason` on `GET /sandboxes/{id}`. The most common cause is a firewall rule inside the sandbox that blocks Boat's tunnel; see [Firewall rules](/hosting#firewall-rules-inside-a-sandbox).

## Payload

```json theme={null}
{
  "id": "evt_2af97cd17a8248bb8fe37653a235bc91",
  "type": "sandbox.ready",
  "createdAt": "2026-08-11T12:00:00.000Z",
  "data": {
    "sandbox": {
      "id": "bx_23456789",
      "name": "Production worker"
    },
    "previousState": "cloning",
    "state": "ready"
  }
}
```

The public sandbox ID is in `data.sandbox.id`. Use the top-level event `id` as an idempotency key. `sandbox.hydrated` uses the same envelope, without `previousState` or `state`. `sandbox.degraded` and `sandbox.recovered` carry `state` and `reason`, without `previousState`.

## Verify signatures

Every request includes:

| Header | Value |
| - | - |
| `X-Ascii-Event` | Event type |
| `X-Ascii-Delivery` | Stable event ID |
| `X-Ascii-Timestamp` | Unix timestamp for this attempt |
| `X-Ascii-Signature` | `v1=` followed by a hex HMAC-SHA256 signature |
| `X-Ascii-Attempt` | Attempt number, starting at 1 |

Compute the expected signature over the exact raw request body:

```text theme={null}
HMAC_SHA256(secret, delivery_id + "." + timestamp + "." + raw_body)
```

Compare signatures in constant time, reject stale timestamps, and deduplicate the delivery ID.

<CodeGroup>
  ```ts TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  function verifyWebhook(rawBody: Buffer, headers: Headers, secret: string) {
    const id = headers.get("x-ascii-delivery") ?? "";
    const timestamp = headers.get("x-ascii-timestamp") ?? "";
    const supplied = (headers.get("x-ascii-signature") ?? "").replace(/^v1=/, "");
    const expected = createHmac("sha256", secret)
      .update(`${id}.${timestamp}.`)
      .update(rawBody)
      .digest("hex");

    const issuedAt = Number(timestamp);
    if (!Number.isFinite(issuedAt) || Math.abs(Date.now() / 1000 - issuedAt) > 300) return false;
    if (!/^[a-f0-9]{64}$/i.test(supplied)) return false;
    return timingSafeEqual(Buffer.from(supplied, "hex"), Buffer.from(expected, "hex"));
  }
  ```

  ```python Python theme={null}
  import hmac, re, time
  from hashlib import sha256

  def verify_webhook(raw_body: bytes, headers, secret: str) -> bool:
      delivery = headers.get("x-ascii-delivery", "")
      timestamp = headers.get("x-ascii-timestamp", "")
      supplied = headers.get("x-ascii-signature", "").removeprefix("v1=")

      try:
          if abs(time.time() - float(timestamp)) > 300:
              return False
      except ValueError:
          return False
      if not re.fullmatch(r"[a-fA-F0-9]{64}", supplied):
          return False

      expected = hmac.new(
          secret.encode(),
          f"{delivery}.{timestamp}.".encode() + raw_body,
          sha256,
      ).hexdigest()
      return hmac.compare_digest(supplied.lower(), expected)
  ```
</CodeGroup>

<Warning>
  Verify the raw bytes before parsing JSON. Re-serializing the body changes the signature.
</Warning>

## Delivery and retries

A `2xx` response marks the event delivered. Other responses, connection failures, or the 5-second timeout retry with exponential backoff for up to 8 attempts. Delivery is **at least once**, so the same event ID can arrive more than once. Separate events can arrive out of order; use each event's ID, creation time, and state rather than assuming request order.

Return a `2xx` quickly and move slow work to your own queue. Completed and exhausted delivery records are retained for 30 days.

## Inspect deliveries

Open [Webhooks](https://boat.dev/dashboard?tab=webhooks) in the Boat dashboard, then expand **Deliveries** beside an endpoint. The recent-delivery log shows what fired, the exact payload, Boat and event IDs, attempt count, HTTP result or connection diagnostic, and the next retry time when applicable.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.