Async AI Video Jobs - Webhooks, Signatures and a Backup Poller
Build an async AI video pipeline that never loses jobs: submit tasks, check HMAC webhook signatures, save files before links expire, repair with a poller.
Generating a video takes minutes, so the request that starts it should not be the one that waits for it. With Kadraris you send POST /v1/generate with a webhook_url, get back a task id right away, and receive a signed POST when the clip is done. Around that sit three small pieces: a handler that checks the x-kadraris-signature header (a hex HMAC-SHA256 of the raw body), a worker that copies the file to your own storage, and a slow poller that fixes anything the webhook missed.
Below is how I set this up for batch video work. The main code is Python with FastAPI, and there is an Express version of the webhook handler as well.
What breaks when you wait on one HTTP call
Depending on the model, the length and the resolution, a clip can be ready in under a minute or take several. Holding a request open for that long goes wrong in ways you can predict:
- a proxy or load balancer drops the idle connection,
- a serverless function runs past its time limit,
- any deploy or restart cuts off every job in progress,
- fifty parallel jobs need fifty open connections.
That is why the API splits a job into two moments. The submit call answers at once with status 202 and a task id. The result comes later, either because you ask for it (GET /v1/tasks/{id}) or because Kadraris calls your webhook.
Four small services
[app] --POST /v1/generate + webhook_url--> [Kadraris]
| <-- 202 {id, status: queued, estimated_cost}
+-- insert row (id, status=queued)
[Kadraris] --signed POST--> [receiver]
|- check signature
|- skip duplicates
|- queue the download
'- mark the row
[reconciler, every few min] --GET /v1/tasks/{id}-->
only rows stuck in queued or runningSubmitter, receiver, downloader, reconciler. None of them is more than a screen of code.
Submitter: create the task and save it first
import os
import sqlite3
import requests
API = "https://kadraris.com/v1"
KEY = os.environ["KADRARIS_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}"}
WEBHOOK_URL = "https://yourapp.example.com/hooks/video"
db = sqlite3.connect("tasks.db", check_same_thread=False)
db.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
model TEXT,
prompt TEXT,
status TEXT,
estimated_cost TEXT,
cost TEXT,
file_path TEXT,
error TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)""")
def submit_video(prompt, model="seedance-2.5", duration=5,
resolution="720p", aspect_ratio="9:16"):
body = {
"model": model,
"input": {
"prompt": prompt,
"duration": duration,
"resolution": resolution,
"aspect_ratio": aspect_ratio,
},
"webhook_url": WEBHOOK_URL,
}
r = requests.post(f"{API}/generate", headers=HEADERS,
json=body, timeout=30)
data = r.json()
if r.status_code != 202:
err = data["error"]
raise RuntimeError(
f"{r.status_code} {err['type']}: {err['message']}")
db.execute(
"INSERT INTO tasks (id, model, prompt, status,"
" estimated_cost) VALUES (?, ?, ?, ?, ?)",
(data["id"], model, prompt, data["status"],
data["estimated_cost"]["amount"]),
)
db.commit()
return data["id"]Two choices in that function matter more than they look:
- The row goes in immediately after the 202. A fast model can finish before your code gets around to saving anything, so the webhook may arrive for an id you have not stored yet. Writing the row first thing keeps that gap tiny, and the receiver below does not crash on unknown ids.
estimated_costis saved with the row. The API calculates it before the job starts, which lets you stop a batch or a user at a budget before the money is spent.
Reacting to submit errors
| Status | Cause | What the pipeline does |
|---|---|---|
| 402 | balance lower than estimated_cost | stop the queue, alert, continue after a top-up |
| 422 | bad input | flag the job as invalid, never retry |
| 429 | too many requests | retry with exponential backoff |
| 502 | upstream model error | retry after a pause |
Receiver: check the signature on raw bytes
When a task ends, Kadraris POSTs to your webhook_url the same JSON that GET /v1/tasks/{id} returns:
{
"id": "task_...",
"status": "succeeded",
"model": "seedance-2.5",
"output": [{ "type": "video", "url": "https://...",
"duration": 5, "width": 720, "height": 1280 }],
"cost": { "amount": "X.XX", "currency": "USD" },
"error": null,
"created_at": "...",
"completed_at": "..."
}The header x-kadraris-signature is a hex HMAC-SHA256 of the raw body, computed with the webhook secret shown in your dashboard. Compute your own over the exact bytes that arrived. If you parse the JSON first and serialize it again, spacing and key order change and the two values will never match.
FastAPI
import hashlib
import hmac
import json
from fastapi import (FastAPI, Request, HTTPException,
BackgroundTasks)
app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"].encode()
def valid_signature(raw: bytes, header: str | None) -> bool:
if not header:
return False
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
@app.post("/hooks/video")
async def video_hook(request: Request,
background: BackgroundTasks):
raw = await request.body()
sig = request.headers.get("x-kadraris-signature")
if not valid_signature(raw, sig):
raise HTTPException(status_code=401,
detail="bad signature")
task = json.loads(raw)
row = db.execute("SELECT status FROM tasks WHERE id = ?",
(task["id"],)).fetchone()
if row and row[0] in ("failed", "stored"):
# seen this one before
return {"ok": True, "duplicate": True}
if task["status"] == "succeeded":
background.add_task(store_output, task)
elif task["status"] == "failed":
db.execute(
"UPDATE tasks SET status = 'failed', cost = '0',"
" error = ?, updated_at = CURRENT_TIMESTAMP"
" WHERE id = ?",
(json.dumps(task["error"]), task["id"]),
)
db.commit()
return {"ok": True}What every receiver I write does:
- Signature before anything else. Missing or wrong means 401, and the body is never parsed.
- Constant-time comparison.
hmac.compare_digestinstead of==, so timing does not leak the expected value. - A quick 2xx. Acknowledge first; downloads and transcoding run in the background.
- Safe to receive twice. The same event can be delivered more than once. Looking up the stored status before acting turns repeats into no-ops.
Express
The trap in Express is a global express.json() that parses the body before you can check it. Give the webhook route express.raw() instead:
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
app.post(
"/hooks/video",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.get("x-kadraris-signature") || "";
const expected = crypto
.createHmac("sha256", SECRET)
.update(req.body)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
const task = JSON.parse(req.body.toString("utf8"));
res.status(200).json({ ok: true });
if (task.status === "succeeded") {
queueDownload(task); // hand off to your queue
} else if (task.status === "failed") {
markFailed(task.id, task.error);
}
},
);
app.listen(3000);Downloader: move the file off the temporary link
Result URLs work for a limited time only. Think of them as a parcel locker, not a shelf. As soon as a task succeeds, fetch the file and store it somewhere you own: S3, R2, GCS or a plain disk.
import pathlib
STORAGE = pathlib.Path("videos")
STORAGE.mkdir(exist_ok=True)
def store_output(task):
item = task["output"][0]
path = STORAGE / f"{task['id']}.mp4"
with requests.get(item["url"], stream=True,
timeout=300) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_content(1 << 20):
f.write(chunk)
db.execute(
"UPDATE tasks SET status = 'stored', cost = ?,"
" file_path = ?, updated_at = CURRENT_TIMESTAMP"
" WHERE id = ?",
(task["cost"]["amount"], str(path), task["id"]),
)
db.commit()A download that crashes is not lost: the reconciler sees the row is still open, reads the task again and retries while the link is valid.
Reconciler: the safety net
The webhook is the quick route, not a guarantee. Your server can be mid-deploy, a firewall can drop the call, a background worker can die. The reconciler looks for rows that have been open too long and asks the API directly:
import time
def reconcile(max_age_minutes=10):
rows = db.execute(
"""SELECT id FROM tasks
WHERE status IN ('queued', 'running')
AND updated_at < datetime('now', ?)""",
(f"-{max_age_minutes} minutes",),
).fetchall()
for (task_id,) in rows:
t = requests.get(f"{API}/tasks/{task_id}",
headers=HEADERS, timeout=30).json()
if t["status"] == "succeeded":
store_output(t)
elif t["status"] == "failed":
db.execute(
"UPDATE tasks SET status = 'failed', cost = '0',"
" error = ? WHERE id = ?",
(json.dumps(t["error"]), task_id))
db.commit()
else:
db.execute(
"UPDATE tasks SET status = ?,"
" updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(t["status"], task_id))
db.commit()
if __name__ == "__main__":
while True:
reconcile()
time.sleep(300)Run it from cron or as a worker process. On a good day it finds nothing to do. On a bad day it puts the table back in order without anyone noticing.
Trying it on your laptop
A local machine cannot receive calls from the internet, so open a tunnel (ngrok, Cloudflare Tunnel or similar) to localhost:8000 and use the public address as webhook_url. Then:
- Send a short, inexpensive job, for instance Seedance 2.5 at 480p for a few seconds.
- Watch the receiver log until the signed POST shows up.
- Save that payload, change a single byte, and send it again with curl. The receiver has to answer 401.
curl -X POST http://localhost:8000/hooks/video \
-H "Content-Type: application/json" \
-H "x-kadraris-signature: 0000" \
--data-binary @payload.jsonMoney: holds, failures, spend report
At submit time Kadraris holds the estimated amount on your balance. If the model fails (a content filter, an upstream error, a timeout), the task ends as failed with an error object and a cost of 0, and the whole hold goes back. Your real spend is therefore the sum of cost over successful tasks, which the table already has:
SELECT model, COUNT(*) AS videos,
SUM(CAST(cost AS REAL)) AS spent
FROM tasks WHERE status = 'stored'
GROUP BY model;Per-second rates for each video model are collected in AI video cost per second.
Growing past a few jobs
When the four parts work, scaling up is mostly settings:
- Concurrency. Feed submissions from a queue with a fixed number of workers and back off on 429.
- Several models. Seedance 2.5, Kling 3.0, Veo 3.1 (Quality), the image models and Mureka V9 all return the same task object, so one pipeline serves them all. Video models are compared in best AI video APIs in 2026.
- Daily caps. Add up
estimated_costfor queued work and refuse new jobs past your limit. - Low-balance alerts. Call
GET /v1/balanceevery hour and alert under a threshold, so the queue never stops on a 402.
Reference pages: generate, tasks and webhooks.
Questions
What should my endpoint return when it gets a webhook?
Any 2xx status, and fast. Check the signature, record what you need, answer, and move slow work such as the file download to a background job.
Can the same webhook arrive more than once?
Plan for it. Keep the task status in your database and ignore an event for a task that is already marked as stored or failed.
Do I still need polling if I use webhooks?
Not for every task, but keep a slow reconciler for tasks that stay queued or running too long. It covers deploys, outages and crashed workers. For a quick one-off script, polling alone is fine.
How long do I have to download the finished video?
The links are temporary, so download right after the task succeeds and keep the file in your own storage. The generation history stays on your account.


























