Webhooks
Event types, signature verification, delivery guarantees, retries and replay.
POST /v1/webhook-endpoints register
GET /v1/webhook-endpoints list
PATCH /v1/webhook-endpoints/{id} update
DELETE /v1/webhook-endpoints/{id} remove
POST /v1/webhook-endpoints/{id}/test send a test event
GET /v1/events list past events
POST /v1/events/{id}/replay redeliver
Register
curl -sS "$WB_BASE_URL/webhook-endpoints" \
-H "Authorization: Bearer $WB_API_KEY" -H "Content-Type: application/json" \
-d '{
"url": "https://api.acme.com/hooks/workspaces-build",
"events": ["session.running", "session.failed", "quota.threshold_reached"],
"description": "agent backend",
"metadata": { "owner": "platform-team" }
}'
{
"id": "whep_4Jd8Pq",
"url": "https://api.acme.com/hooks/workspaces-build",
"events": ["session.running", "session.failed", "quota.threshold_reached"],
"secret": "whsec_8Pq2Vd4Lm9Kx…",
"state": "active"
}
secret is shown once. Store it; you need it to verify signatures.
Subscribe to ["*"] for everything, or to a prefix like session.*.
Payload
{
"id": "evt_9Km2Xp",
"object": "event",
"type": "session.failed",
"created_at": "2026-10-06T09:42:11.402Z",
"organization_id": "org_2kQ9xw",
"project_id": "proj_7fTn21",
"data": {
"object": {
"id": "sess_2kQ9xwR4",
"object": "session",
"tier": 2,
"state": "failed",
"state_reason": "oom",
"end_user_id": "eu_customer_4821",
"usage_to_date": { "session_s": 402, "cpu_ms": 118430 }
},
"previous": { "state": "running" }
}
}
data.object is the full object in its new state. data.previous carries only the changed fields.
Signature verification
WB-Signature: t=1759741331,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd WB-Event-Id: evt_9Km2Xp WB-Delivery-Attempt: 1
The signed payload is {timestamp}.{raw_body}, HMAC-SHA256 with the endpoint secret.
import crypto from 'node:crypto'
function verify(rawBody: string, header: string, secret: string, toleranceS = 300): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const timestamp = Number(parts.t)
if (!Number.isFinite(timestamp)) return false
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceS) return false // replay window
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ''))
}
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance_s: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
if abs(time.time() - ts) > tolerance_s:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Three things that are easy to get wrong:
- Verify against the raw body, before any JSON parsing. Re-serialising changes the bytes and the signature will not match.
- Enforce the timestamp tolerance. Without it a captured request can be replayed forever.
- Use a constant-time comparison. A naive
==leaks the signature one byte at a time.
Event types
Sessions
| Event | When |
|---|---|
session.created | Accepted, provisioning |
session.running | Ready to use |
session.failed | Terminal, unclean; state_reason explains |
session.stopped | Terminal, clean |
session.expired | Maximum lifetime reached |
session.paused / session.resumed | — |
session.idle_warning | 80% of the idle timeout |
session.lifetime_warning | 90% of the maximum lifetime |
session.evicted | Preempted or drained; notice_s says how long you have |
session.handoff | Tier 4 control transferred to or from a human |
Runs
run.started, run.succeeded, run.failed, run.timed_out, run.cancelled.
High volume. Subscribe only if you are not already watching the run's stream.
Storage
snapshot.ready, snapshot.failed, snapshot.expired, volume.available, volume.error.
Policy and security
| Event | When |
|---|---|
policy.violation | A session was killed by policy |
egress.denied_spike | Denial rate for one session far above baseline — a strong prompt-injection signal |
api_key.expiring | 30, 7 and 1 days before expiry |
api_key.auto_revoked | A leaked key was found and revoked |
Quota and billing
quota.threshold_reached, quota.exceeded, credit.low, invoice.finalized,
invoice.payment_failed, invoice.paid.
Templates
template.ready, template.failed, template.scan_failed.
Delivery
| Property | Value |
|---|---|
| Guarantee | At least once |
| Ordering | Not guaranteed — use created_at and object state, not arrival order |
| Timeout | 10 s per attempt |
| Success | Any 2xx |
| Retries | 8 attempts over ~24 h: 10 s, 30 s, 2 m, 10 m, 1 h, 3 h, 6 h, 12 h, jittered |
| Concurrency | Up to 8 in flight per endpoint |
| Replay window | 30 days |
Handlers must be idempotent — deduplicate on id — and fast: acknowledge with a 2xx, then
do the work asynchronously. A handler that takes 12 seconds is a handler that times out and gets
retried, which produces the duplicates the idempotency requirement exists to absorb.
app.post('/hooks/workspaces-build', async (req, res) => {
if (!verify(req.rawBody, req.get('WB-Signature'), SECRET)) return res.sendStatus(400)
const event = JSON.parse(req.rawBody)
if (await seen(event.id)) return res.sendStatus(200) // duplicate
await enqueue(event) // process out of band
res.sendStatus(200) // acknowledge immediately
})
Circuit breaker
An endpoint failing more than 50% of deliveries over an hour is disabled. The organization's owners
are notified and the endpoint's state becomes disabled. Re-enable it with PATCH; events from the
disabled window remain replayable.
Events API
Webhooks are a convenience over an event log that is queryable directly.
curl -sS "$WB_BASE_URL/events?type=session.failed&created_after=2026-10-01T00:00:00Z&limit=100" \ -H "Authorization: Bearer $WB_API_KEY"
curl -sS -X POST "$WB_BASE_URL/events/evt_9Km2Xp/replay" \
-H "Authorization: Bearer $WB_API_KEY" -H "Content-Type: application/json" \
-d '{ "endpoint_id": "whep_4Jd8Pq" }'
Events are retained for 30 days. If your handler was down, replay the window rather than reconstructing state by polling.
Local development
curl -sS -X POST "$WB_BASE_URL/webhook-endpoints/whep_4Jd8Pq/test" \
-H "Authorization: Bearer $WB_API_KEY" -H "Content-Type: application/json" \
-d '{ "type": "session.failed" }'
Sends a realistic event with a valid signature. For a local endpoint, use a tunnel, or a test-mode
key — test mode emits the full event stream against simulated compute, so a webhook integration can
be built end to end without starting a single sandbox.