Webhooks

Receive a signed POST when a task finishes: event types, headers, payload, retries and signature verification in Node.js and Python.

Set callback_url when you create a task and we POST the task to that URL as soon as it reaches a final status. No polling needed.

Events

EventSent when
task.succeededThe task succeeded; output holds the result URLs.
task.failedThe task failed (including timeouts); credits were refunded.
task.canceledThe task was canceled; credits were refunded.

Each task sends at most one event, for its final status. Tasks without a callback_url send none.

The request

HTTP
POST /webhooks/genkiln HTTP/1.1
Host: your-app.com
Content-Type: application/json
User-Agent: genkiln-webhooks/1.0
Genkiln-Event: task.succeeded
Genkiln-Delivery: dlv_01JC8ZT6V2B9Q4K7M1N3P5R8SX
Genkiln-Signature: t=1793669519,v1=42795bb28583b93ce4d9a0edddda87676f8e619e096d25285a470a905847cb0c

{"id":"task_01JC8ZQ0E6H2P4N7K9T3W5Y8VA","object":"task","model":"veo-3.1-fast","status":"succeeded", ...}
HeaderDescription
Genkiln-EventThe event type, e.g. task.succeeded.
Genkiln-DeliveryUnique ID of this event. It stays the same on every retry, so use it to ignore duplicates.
Genkiln-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>. See Verify the signature.

The body is the task object, exactly as GET /v1/tasks/{id} would return it, including your metadata.

Responding and retries

  • Return any 2xx status within 10 seconds. Do the heavy work (downloading files, updating your database) after you respond, or in a queue.
  • Anything else counts as a failure: a non-2xx status, a timeout, a connection error, or a redirect (we do not follow redirects, so use the final URL).
  • Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes and 2 hours, for 5 attempts in total. After the last attempt the delivery is marked failed. You can always fetch the task with Get task.
  • Because of retries, the same event can arrive more than once. Deduplicate on Genkiln-Delivery or on the task id + status.
  • callback_url must stay a public https URL. It is checked again before each delivery.

Your signing secret

Every account has one webhook signing secret, starting with whsec_. Find it, or rotate it, in the console. Rotating it takes effect immediately for every delivery after the change, including retries, so update your server right away.

Verify the signature

The signature proves the request came from us and was not changed. To check it:

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Split Genkiln-Signature on , into t and v1.
  3. Compute HMAC-SHA256 with your full secret (including the whsec_ prefix) as the key, over the string t + "." + raw body. Hex-encode it.
  4. Compare it with v1 using a constant-time comparison.
  5. Reject the request if t is more than 5 minutes away from your current time, to block replays.

Node.js

Node.js
import crypto from 'node:crypto';

export function verifyGenkilnSignature(
  rawBody,
  header,
  secret,
  toleranceSec = 300
) {
  if (!header) return false;
  const parts = {};
  for (const item of header.split(',')) {
    const i = item.indexOf('=');
    if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest();
  const received = Buffer.from(parts.v1, 'hex');
  return (
    received.length === expected.length &&
    crypto.timingSafeEqual(received, expected)
  );
}

With Express, keep the raw body for this route:

Node.js
import express from 'express';

const app = express();

app.post(
  '/webhooks/genkiln',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = verifyGenkilnSignature(
      req.body.toString('utf8'),
      req.get('Genkiln-Signature'),
      process.env.GENKILN_WEBHOOK_SECRET
    );
    if (!ok) return res.status(400).send('invalid signature');

    const task = JSON.parse(req.body.toString('utf8'));
    res.sendStatus(200); // respond first, then process
    handleTask(req.get('Genkiln-Event'), req.get('Genkiln-Delivery'), task);
  }
);

Python

Python
import hashlib
import hmac
import time


def verify_genkiln_signature(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
    if not header:
        return False
    parts = {}
    for item in header.split(","):
        key, sep, value = item.partition("=")
        if sep:
            parts[key.strip()] = value.strip()
    try:
        t = int(parts["t"])
        received = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(int(time.time()) - t) > tolerance_sec:
        return False

    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

With Flask:

Python
import json
import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/webhooks/genkiln")
def genkiln_webhook():
    raw = request.get_data()  # raw bytes, not request.json
    if not verify_genkiln_signature(
        raw, request.headers.get("Genkiln-Signature", ""), os.environ["GENKILN_WEBHOOK_SECRET"]
    ):
        return "invalid signature", 400

    task = json.loads(raw)
    event = request.headers.get("Genkiln-Event")
    delivery_id = request.headers.get("Genkiln-Delivery")
    # enqueue (event, delivery_id, task) for processing, then:
    return "", 200

Verify against the exact bytes you received. Parsing the JSON and serializing it again changes the bytes and the signature will not match.

Test locally

Your endpoint must be reachable over public https, so localhost will not work. During development, expose your local server with a tunnel and use the tunnel's https URL as callback_url.