Webhooks

Subscribe to sandbox, runner, and device lifecycle events with signed, idempotent, retried HTTP callbacks.


Webhooks let you react to infrastructure events, a sandbox coming up, a runner job finishing, a snapshot completing, without polling the API.

Configure an endpoint#

sandal webhooks create --url https://example.com/hooks/sandal --events sandbox.ready,sandbox.error,sandbox.archived

Or from the dashboard: Settings → Webhooks → Add endpoint. Each endpoint gets its own signing secret.

Events#

EventFired when
sandbox.createdA sandbox has been provisioned but isn't yet accepting exec/fs calls
sandbox.readyA sandbox has booted and is accepting commands
sandbox.pausedA sandbox was paused (billing drops to $0)
sandbox.resumedA sandbox resumed from a warm claim or cold restore
sandbox.errorA sandbox crashed or failed to boot
sandbox.archivedA sandbox was torn down and its final snapshot persisted
sandbox.snapshot.completedAn incremental snapshot finished writing
runner.job.queuedA CI job was assigned to a runner pool
runner.job.completedA CI job finished (includes conclusion: success/failure/cancelled)
device.session.readyA simulator/emulator session is streamable
device.session.endedA device session was torn down

Payload shape#

{
  "id": "evt_5f0c8a3e",
  "type": "sandbox.ready",
  "createdAt": "2026-08-25T10:04:02.113Z",
  "data": {
    "sandboxId": "sb_9f1c2a",
    "template": "node-24",
    "region": "eu"
  }
}

id is a globally unique event id, use it for idempotency (see below). type matches the event names in the table above.

Verifying signatures#

Every request carries two headers:

Sandal-Signature: t=1756123442,v1=5257a869e7bfce...
Sandal-Event-Id: evt_5f0c8a3e

The signature is HMAC-SHA256 over {timestamp}.{raw request body}, computed with your endpoint's signing secret, hex-encoded.

Verification steps#

  1. Parse t and v1 out of Sandal-Signature.
  2. Reject if t is more than 5 minutes from the current time (freshness check, blocks replay of captured requests).
  3. Recompute the HMAC over ${t}.${rawBody} using your signing secret.
  4. Compare to v1 using a constant-time comparison, not ===/==.
  5. Use the raw request body (before any JSON parsing/re-serialization), re-serializing can change byte-for-byte content and break the signature.

Node.js example#

import { createHmac, timingSafeEqual } from "node:crypto";

function verifySandalWebhook(rawBody: string, header: string, secret: string) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string])
  );
  const timestamp = Number(parts.t);
  const signature = parts.v1;

  const fiveMinutes = 5 * 60;
  if (Math.abs(Date.now() / 1000 - timestamp) > fiveMinutes) {
    throw new Error("Webhook timestamp too old, possible replay");
  }

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error("Invalid webhook signature");
  }
}

Python example#

import hashlib
import hmac
import time

def verify_sandal_webhook(raw_body: bytes, header: str, secret: str) -> None:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = int(parts["t"])
    signature = parts["v1"]

    if abs(time.time() - timestamp) > 5 * 60:
        raise ValueError("Webhook timestamp too old, possible replay")

    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        raise ValueError("Invalid webhook signature")

Idempotency#

Sandal delivers at-least-once, so the same event can arrive more than once (retries, network duplication). Every event carries a stable id (Sandal-Event-Id header, also data.id in the body), store processed event ids (with a TTL of a few days is enough) and skip any id you've already handled, before performing side effects.

if (await seenEvents.has(event.id)) return res.status(200).end();
await seenEvents.add(event.id);
// ...handle event

Retry policy#

If your endpoint doesn't respond with a 2xx within 10 seconds, Sandal treats the delivery as failed and retries with exponential backoff:

AttemptDelay after previous attempt
1immediate
230s
32m
410m
51h
66h
7 (final)24h

After the final attempt fails, the event is marked failed and visible in Settings → Webhooks → Delivery log, where you can inspect the payload and manually redeliver it. Endpoints that fail more than 90% of deliveries over a rolling 24h window are automatically disabled and you'll get an email.

Return a 2xx as soon as you've durably queued the event (e.g. written it to a DB or job queue), do the actual work asynchronously so slow downstream processing doesn't trigger spurious retries.