Webhooks#

Webhooks notify your backend when a sandbox changes state or your usage crosses a threshold, so you don't have to poll. Every delivery is signed with HMAC-SHA256.

Events#

Event Sent when
sandbox.ready A new sandbox (created or forked) becomes running
sandbox.paused A sandbox reaches paused, whether through the API, auto-pause or the spend limit
sandbox.resumed A paused sandbox is running again, through the API or wake on request
sandbox.error A sandbox enters error
sandbox.deleted A sandbox reaches deleted, through the API or when ttl_seconds expires
usage.threshold Usage crosses a threshold: a spend-limit alert (alert_thresholds, default 50%, 80%, 100% of monthly_limit_usd), 80% or 100% of the plan credit for the period, or 80% or 100% of the Free plan's 20 hours. Sent once per threshold and period
webhook.test You call POST /webhooks/{webhook_id}/test
sandbox.restored_from_checkpoint A sandbox runs again from an older saved state: its host failed while it was running, or restoring its memory on a new host could not finish. Work after checkpoint.taken_at is lost. See Checkpoints and host failures

Payload#

{
  "id": "evt_2af7cd3ka7zq",
  "type": "sandbox.paused",
  "created_at": "2026-11-09T12:00:00Z",
  "data": {
    "sandbox": { "id": "sbx_2fqxnt4bkq7m", "name": "user-42", "state": "paused", "size": "default", "labels": { "customer": "acme" } },
    "previous_state": "pausing",
    "state": "paused",
    "reason": "auto_pause"
  }
}
  • data.sandbox is the full Sandbox object. The example above is abbreviated.
  • data.reason explains the transition. Possible values:
    • api
    • auto_pause
    • wake_on_request
    • spend_limit
    • ttl_expired
    • host_maintenance
    • host_lost: the host failed; the sandbox is restored from its newest checkpoint
    • an error code, for sandbox.error

sandbox.restored_from_checkpoint payload#

{
  "id": "evt_9d2c4f7a1b3e",
  "type": "sandbox.restored_from_checkpoint",
  "created_at": "2026-11-09T12:00:05Z",
  "data": {
    "sandbox": { "id": "sbx_2fqxnt4bkq7m", "state": "running", "size": "default" },
    "reason": "host_lost",
    "ram_lost": true,
    "checkpoint": { "generation": 14, "kind": "disk", "taken_at": "2026-11-09T11:42:00Z" },
    "data_loss_window_seconds": 1085
  }
}
  • reason: host_lost (the host failed while the sandbox ran), restore_interrupted (memory of a paused sandbox could not be fully restored on its new host, so it was restored again from the same saved state) or resume (a resume had only a disk checkpoint to use).
  • ram_lost: true: the sandbox was restored from a disk checkpoint and booted. Files are as of taken_at; processes, /dev/shm and other in-memory state restarted. Re-run your agent's setup or resume from its own on-disk progress.
  • ram_lost: false: restored from a pause snapshot (kind: memory): memory, processes and files are as of taken_at.

usage.threshold payload#

{
  "id": "evt_7c1d0e5b2a90",
  "type": "usage.threshold",
  "created_at": "2026-11-20T14:02:11Z",
  "data": {
    "kind": "spend_limit",
    "percent": 100,
    "threshold_usd": 50,
    "amount_usd": 50.02,
    "limit_usd": 50,
    "action": "paused_running",
    "period_start": "2026-11-09T00:00:00Z",
    "period_end": "2026-12-09T00:00:00Z"
  }
}
  • kind: spend_limit (amount due vs monthly_limit_usd), credit (usage vs the plan credit of the period) or free_allowance (usage vs the Free plan's 20 hours).
  • action: what TaskCloud did. paused_running: running sandboxes are being paused, and each one also sends sandbox.paused with reason: "spend_limit". blocked_new: new starts get 402. none: an alert only.

Headers#

Header Value
Webhook-Id Event ID (evt_...). Stays the same across retries, so use it to de-duplicate.
Webhook-Timestamp Unix time in seconds when this attempt was signed
Webhook-Signature v1=<hex>. During secret rotation it holds two space-separated values, e.g. v1=abc... v1=def...
Webhook-Event The event type, e.g. sandbox.paused
Webhook-Attempt Attempt number, starting at 1

Creating an endpoint#

curl -X POST https://api.task.cloud/v1/webhooks \
  -H "Authorization: Bearer $TASKCLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/taskcloud", "events": ["sandbox.ready", "sandbox.paused", "sandbox.resumed", "sandbox.error", "sandbox.deleted"], "description": "prod"}'
const webhook = await client.webhooks.create({
  url: "https://example.com/hooks/taskcloud",
  events: ["sandbox.ready", "sandbox.paused", "sandbox.error"],
  description: "prod",
});
console.log(webhook.secret); // whsec_..., shown only once
webhook = client.webhooks.create(
    url="https://example.com/hooks/taskcloud",
    events=["sandbox.ready", "sandbox.paused", "sandbox.error"],
    description="prod",
)
print(webhook.secret)  # shown only once
taskcloud webhook create --url https://example.com/hooks/taskcloud --event sandbox.ready --event sandbox.paused
taskcloud webhook ls
taskcloud webhook test whk_5mz2qk7hbd4x

Requirements and limits:

  • Up to 10 endpoints per account. This requires the webhooks:manage scope.
  • Endpoints must use HTTPS on port 443 and resolve to a public IP. Redirects are not followed.
  • The secret (whsec_...) is returned only once, in the create or rotate response.

Verifying signatures#

Compute the signature as follows:

signed_payload = Webhook-Id + "." + Webhook-Timestamp + "." + raw_request_body
signature      = hex( HMAC-SHA256( key = secret, message = signed_payload ) )

The key is the whole secret string, including the whsec_ prefix, encoded as UTF-8.

  1. Read the raw request body. Don't re-serialize parsed JSON.
  2. Reject the request if Webhook-Timestamp is more than 300 seconds from your clock.
  3. Compute the expected signature and compare it in constant time with each v1= value in Webhook-Signature. Accept the request if any value matches.
  4. De-duplicate on Webhook-Id.
  5. Respond with 2xx quickly. Do slow work asynchronously.

TypeScript (Express, Node crypto)#

import express from "express";
import crypto from "node:crypto";

const SECRET = process.env.WEBHOOK_SECRET!; // "whsec_..."
const app = express();

function verify(rawBody: Buffer, headers: express.Request["headers"]): boolean {
  const id = headers["webhook-id"] as string | undefined;
  const ts = headers["webhook-timestamp"] as string | undefined;
  const sigHeader = headers["webhook-signature"] as string | undefined;
  if (!id || !ts || !sigHeader) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${id}.${ts}.`)
    .update(rawBody)
    .digest();

  return sigHeader.split(" ").some((part) => {
    const [version, hex] = part.split("=", 2);
    if (version !== "v1" || !hex) return false;
    const given = Buffer.from(hex, "hex");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

app.post("/hooks/taskcloud", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.headers)) return res.status(400).send("invalid signature");
  const event = JSON.parse(req.body.toString("utf8"));
  // TODO: skip if event.id was already processed
  console.log(event.type, event.data.sandbox.id, event.data.reason);
  res.sendStatus(204);
});

app.listen(3000);

Python (Flask)#

import hashlib, hmac, json, os, time
from flask import Flask, request, abort

SECRET = os.environ["WEBHOOK_SECRET"].encode()  # b"whsec_..."
app = Flask(__name__)


def verify(raw_body: bytes, headers) -> bool:
    msg_id = headers.get("Webhook-Id")
    ts = headers.get("Webhook-Timestamp")
    sig_header = headers.get("Webhook-Signature")
    if not (msg_id and ts and sig_header):
        return False
    try:
        if abs(time.time() - int(ts)) > 300:
            return False
    except ValueError:
        return False
    expected = hmac.new(SECRET, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(
        part.startswith("v1=") and hmac.compare_digest(part[3:], expected)
        for part in sig_header.split(" ")
    )


@app.post("/hooks/taskcloud")
def hook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(raw, request.headers):
        abort(400)
    event = json.loads(raw)
    # TODO: skip if event["id"] was already processed
    print(event["type"], event["data"]["sandbox"]["id"], event["data"].get("reason"))
    return "", 204

With FastAPI, read await request.body() and pass the bytes to the same verify function.

Using the SDK helpers#

import { verifyWebhook } from "@taskcloud/sdk/webhooks";

app.post("/hooks/taskcloud", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = verifyWebhook({ payload: req.body, headers: req.headers, secret: process.env.WEBHOOK_SECRET! });
    // event is the parsed, typed WebhookEvent
    res.sendStatus(204);
  } catch {
    res.status(400).send("invalid signature");
  }
});
from taskcloud.webhooks import verify_webhook

event = verify_webhook(payload=request.get_data(), headers=request.headers, secret=os.environ["WEBHOOK_SECRET"])
# raises an exception if the signature or timestamp is invalid

Delivery and retries#

  • Concurrent. Each endpoint gets at most 2 deliveries at a time, and a slow endpoint does not delay other endpoints.
  • At-least-once, unordered. The same event can arrive more than once, and sandbox.resumed can arrive before sandbox.paused. Use Webhook-Id to de-duplicate, and compare created_at (or call GET /sandboxes/{sandbox_id}) for the current state.
  • Success is any 2xx response within 10 seconds. Anything else, including a timeout, is retried with exponential backoff, for up to 8 attempts over 24 hours.
  • Delivery history for the last 30 days: GET /webhooks/{webhook_id}/deliveries.
  • Pause an endpoint with PATCH /webhooks/{webhook_id} and {"enabled": false}.

Rotating the secret#

POST /webhooks/{webhook_id}/rotate-secret returns a new secret. For the next 24 hours, every delivery is signed with both the old and the new secret (two v1= values in Webhook-Signature).

  1. Deploy the new secret.
  2. Confirm that verification still passes.
  3. Let the old secret lapse.
const { secret } = await client.webhooks.rotateSecret("whk_5mz2qk7hbd4x");
secret = client.webhooks.rotate_secret("whk_5mz2qk7hbd4x").secret
taskcloud webhook rotate whk_5mz2qk7hbd4x

Testing#

POST /webhooks/{webhook_id}/test sends a signed webhook.test event and returns the delivery result: status, response_status and duration_ms. To test against a local server, expose it through a public HTTPS tunnel, or run the receiver inside a sandbox and expose its port (see Networking).