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

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({
    webhookCreateRequest: {
      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",
    webhookUpdateRequest: { 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. |
| `sandbox.error`    | A Sandbox enters `error`.                                                                                                                                                              |
| `sandbox.archived` | A Sandbox finishes stopping and enters `archived`.                                                                                                                                     |

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

## 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`.

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