Skip to content

Developers / Webhooks

Webhooks

Receive an HTTP POST when work finishes, instead of polling.

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.completedA generation finished; data.outputs lists the created assets.
generation.failedA generation failed or was cancelled; its credits were returned.
workflow.completedA Canvas run finished; includes output asset ids.
workflow.failedA Canvas run stopped; succeeded steps were charged, failed ones were not.
export.completedAn editor export is ready; includes the new asset id.
export.failedAn 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.

Full API reference →