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.archivedOr from the dashboard: Settings → Webhooks → Add endpoint. Each endpoint gets its own signing secret.
Events#
| Event | Fired when |
|---|---|
sandbox.created | A sandbox has been provisioned but isn't yet accepting exec/fs calls |
sandbox.ready | A sandbox has booted and is accepting commands |
sandbox.paused | A sandbox was paused (billing drops to $0) |
sandbox.resumed | A sandbox resumed from a warm claim or cold restore |
sandbox.error | A sandbox crashed or failed to boot |
sandbox.archived | A sandbox was torn down and its final snapshot persisted |
sandbox.snapshot.completed | An incremental snapshot finished writing |
runner.job.queued | A CI job was assigned to a runner pool |
runner.job.completed | A CI job finished (includes conclusion: success/failure/cancelled) |
device.session.ready | A simulator/emulator session is streamable |
device.session.ended | A 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_5f0c8a3eThe signature is HMAC-SHA256 over {timestamp}.{raw request body}, computed with your endpoint's signing secret, hex-encoded.
Verification steps#
- Parse
tandv1out ofSandal-Signature. - Reject if
tis more than 5 minutes from the current time (freshness check, blocks replay of captured requests). - Recompute the HMAC over
${t}.${rawBody}using your signing secret. - Compare to
v1using a constant-time comparison, not===/==. - 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 eventRetry policy#
If your endpoint doesn't respond with a 2xx within 10 seconds, Sandal treats the delivery as failed and retries with exponential backoff:
| Attempt | Delay after previous attempt |
|---|---|
| 1 | immediate |
| 2 | 30s |
| 3 | 2m |
| 4 | 10m |
| 5 | 1h |
| 6 | 6h |
| 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.