Set up
Add an endpoint in Settings → API → Webhooks (Pro plan and above) or with POST /webhooks. Choose events or all events. Copy the signing secret — it is shown once; rotate it any time.
Events
| generation.completed | A generation finished; data.outputs lists the created assets. |
| generation.failed | A generation failed or was cancelled; its credits were returned. |
| workflow.completed | A Canvas run finished; includes output asset ids. |
| workflow.failed | A Canvas run stopped; succeeded steps were charged, failed ones were not. |
| export.completed | An editor export is ready; includes the new asset id. |
| export.failed | An editor export could not be rendered. |
Payload
Every request is a JSON event: { id, type, created_at, workspace_id, data }. Files are referenced by asset id; download them with GET /assets/{id}/download using your API key.
POST /your/endpoint
content-type: application/json
webhook-id: evt_5f2c9b1e7a6d4c3b8e9f0a1b2c3d4e5f
webhook-timestamp: 1791460800
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "evt_5f2c9b1e7a6d4c3b8e9f0a1b2c3d4e5f",
"type": "generation.completed",
"created_at": "2026-10-08T12:00:00.000Z",
"workspace_id": "0b1c…",
"data": {
"id": "7d2e…",
"status": "completed",
"tool": "api",
"mode": "text_to_video",
"model_id": "kling-3-pro",
"project_id": null,
"credits_charged": 68,
"outputs": [
{
"asset_id": "9a8b…",
"kind": "video"
}
],
"error": null
}
}Verifying signatures
Requests follow the Standard Webhooks spec. Compute HMAC-SHA256 over "{webhook-id}.{webhook-timestamp}.{raw body}" with the base64-decoded part of your whsec_ secret, base64-encode it, and compare it in constant time with the v1 value in webhook-signature. Reject timestamps older than five minutes.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the exact bytes you received (before JSON parsing)
export function verify(secret: string, headers: Record<string, string>, rawBody: string) {
const id = headers['webhook-id'];
const ts = Number(headers['webhook-timestamp']);
if (!id || !ts || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = 'v1,' + createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
return headers['webhook-signature'].split(' ').some((sig) =>
sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}Python
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id, ts = headers["webhook-id"], int(headers["webhook-timestamp"])
if abs(time.time() - ts) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{ts}.".encode() + raw_body
expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(sig, expected) for sig in headers["webhook-signature"].split(" "))Retries and failures
Answer with any 2xx within 10 seconds. Other responses and timeouts are retried 7 more times over about 11 hours (30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h). webhook-id stays the same across retries, so use it to ignore duplicates. After 15 failed events in a row the endpoint is disabled and its creator is notified; re-enable it after fixing the receiver.
Security
Endpoints must be public https URLs; private network addresses are refused, including after redirects. Payloads never contain provider credentials.
