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
| Event | Sent when |
|---|---|
task.succeeded | The task succeeded; output holds the result URLs. |
task.failed | The task failed (including timeouts); credits were refunded. |
task.canceled | The 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
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", ...}| Header | Description |
|---|---|
Genkiln-Event | The event type, e.g. task.succeeded. |
Genkiln-Delivery | Unique ID of this event. It stays the same on every retry, so use it to ignore duplicates. |
Genkiln-Signature | t=<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
2xxstatus 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-Deliveryor on the taskid+status. callback_urlmust stay a publichttpsURL. 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:
- Read the raw request body as bytes, before any JSON parsing.
- Split
Genkiln-Signatureon,intotandv1. - Compute
HMAC-SHA256with your full secret (including thewhsec_prefix) as the key, over the stringt + "." + raw body. Hex-encode it. - Compare it with
v1using a constant-time comparison. - Reject the request if
tis more than 5 minutes away from your current time, to block replays.
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:
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
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:
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 "", 200Verify 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.