PL Log inOpen Studio
Preview version. Model catalog, prices, calculator and docs are live. Generation goes live at launch.

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

text
[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 running

Submitter, receiver, downloader, reconciler. None of them is more than a screen of code.

Submitter: create the task and save it first

python
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_cost is 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

StatusCauseWhat the pipeline does
402balance lower than estimated_coststop the queue, alert, continue after a top-up
422bad inputflag the job as invalid, never retry
429too many requestsretry with exponential backoff
502upstream model errorretry 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:

json
{
  "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

python
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_digest instead 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:

javascript
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);

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.

python
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:

python
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:

  1. Send a short, inexpensive job, for instance Seedance 2.5 at 480p for a few seconds.
  2. Watch the receiver log until the signed POST shows up.
  3. Save that payload, change a single byte, and send it again with curl. The receiver has to answer 401.
bash
curl -X POST http://localhost:8000/hooks/video \
  -H "Content-Type: application/json" \
  -H "x-kadraris-signature: 0000" \
  --data-binary @payload.json

Money: 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:

sql
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_cost for queued work and refuse new jobs past your limit.
  • Low-balance alerts. Call GET /v1/balance every 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.