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.sandboxis the full Sandbox object. The example above is abbreviated.data.reasonexplains the transition. Possible values:apiauto_pausewake_on_requestspend_limitttl_expiredhost_maintenancehost_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) orresume(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 oftaken_at; processes,/dev/shmand 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 oftaken_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 vsmonthly_limit_usd),credit(usage vs the plan credit of the period) orfree_allowance(usage vs the Free plan's 20 hours).action: what TaskCloud did.paused_running: running sandboxes are being paused, and each one also sendssandbox.pausedwithreason: "spend_limit".blocked_new: new starts get402.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:managescope. - 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.
- Read the raw request body. Don't re-serialize parsed JSON.
- Reject the request if
Webhook-Timestampis more than 300 seconds from your clock. - Compute the expected signature and compare it in constant time with each
v1=value inWebhook-Signature. Accept the request if any value matches. - De-duplicate on
Webhook-Id. - Respond with
2xxquickly. 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.resumedcan arrive beforesandbox.paused. UseWebhook-Idto de-duplicate, and comparecreated_at(or callGET /sandboxes/{sandbox_id}) for the current state. - Success is any
2xxresponse 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).
- Deploy the new secret.
- Confirm that verification still passes.
- 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).