Webhooks
Webhooks are how paused agents wake up. Register an endpoint in Dashboard → Webhooks and every state change in your workspace is POSTed to it, signed, within moments of happening.
Events
| Event | Fires when |
|---|---|
| request.created | A request was persisted and the approver notified. |
| request.approved | A human approved — possibly with an edited payload. |
| request.rejected | A human rejected. The note, if any, is included. |
| request.expired | The TTL elapsed with no decision. |
Delivery payload
{
"event": "request.approved",
"createdAt": "2026-07-15T20:05:00.000Z",
"data": {
"id": "clx…",
"status": "APPROVED",
"summary": "Refund $10,000 to customer #4821",
"payload": { "action": "refund", "amount": 10000 },
"modifiedPayload": { "action": "refund", "amount": 10.00 },
"effectivePayload": { "action": "refund", "amount": 10.00 },
"metadata": { "runId": "run_442" },
"agentState": { "conversation": "…", "plan": "…" },
"reasoning": "Order #88213 shows a duplicate charge; refund resolves ticket #5521.",
"recentActions": ["Read ticket #5521", "Checked order history"],
"agentName": "SupportBot",
"approverEmail": null,
"approverGroup": { "key": "finance", "name": "Finance" },
"resolvedAt": "2026-07-15T20:05:00.000Z",
"resolvedByEmail": "maya@company.com",
"resolutionNote": "Fixed the amount.",
"expiresAt": "2026-07-16T20:00:00.000Z",
"createdAtRequest": "2026-07-15T20:00:00.000Z"
}
}Deliveries also carry three headers:
| Header | Contents |
|---|---|
| X-Confirm-Event | The event name, duplicated for cheap routing. |
| X-Confirm-Delivery-Id | Stable per delivery — use it as an idempotency key. |
| X-Confirm-Signature | t=<unix_ts>,v1=<hex> — the HMAC described below. |
Verifying signatures
Each endpoint has a whsec_… secret, shown once at creation. The signature is HMAC-SHA256 over "<timestamp>.<raw body>". Verify with a timing-safe comparison and reject stale timestamps to block replays:
import { createHmac, timingSafeEqual } from "crypto";
export function verifyConfirmSignature(
secret: string,
header: string, // X-Confirm-Signature
rawBody: string, // the unparsed request body
toleranceSec = 300,
) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}GOTCHA
Retries & reliability
- Any 2xx from your endpoint counts as delivered.
- Non-2xx responses and timeouts (10s) are retried up to 3 attempts with backoff.
- Every attempt is recorded — endpoint, event, HTTP status, error — and visible on the request's detail page in the dashboard.
- Design your handler to be idempotent using
X-Confirm-Delivery-Id; retries mean you can occasionally see the same delivery twice.
Resuming after a long wait
Humans take hours. If your agent can't hold memory that long, pass its serialized context as agentState when creating the request (up to 256KB of JSON). We store it and hand it back in every webhook and GET, so the worker that receives request.approved can be a completely fresh process: rebuild the agent from data.agentState, execute data.effectivePayload, and continue. The approver never sees this field. Frameworks with their own durable checkpoints (LangGraph) can store just a thread ID in metadata instead; see the LangGraph guide.
Webhooks are the recommended integration, but they're optional — polling GET /v1/requests/:id gives identical data with more latency.