DEV Community

Cover image for Handling long-running AI video renders: 202 Accepted, polling and signed webhooks (with a working example)
Usama Javed
Usama Javed

Posted on

Handling long-running AI video renders: 202 Accepted, polling and signed webhooks (with a working example)

AI video generation isn't like calling a normal REST endpoint. A single talking-avatar clip can take anywhere from several seconds to a few minutes to render, so an API that holds the HTTP connection open until the file is ready will time out, block your workers and make retries dangerous (did the first request go through? did you just pay twice?).

In this post I'll walk through the pattern most video/ML APIs use to deal with this, and show it end to end against a real API.

Disclosure: I work with the team behind VlogMe, so that's the API I use in the examples. The patterns themselves (202 + job resource, idempotency keys, signed webhooks) apply to almost any async generation API, so you can take the code and adapt it.

The pattern in one picture

client ── POST /renders (Idempotency-Key) ──▶ API
client ◀── 202 Accepted { id, status } ────── API     (returns immediately)

      ...render runs in the background...

option A: client ── GET /jobs/:id ──▶ API   (poll every ~10s until done)
option B: API ── POST webhook_url (signed) ──▶ your server
Enter fullscreen mode Exit fullscreen mode

Three ideas carry the whole design:

  1. Return 202 immediately with a job id instead of waiting for the result.
  2. Make the create call idempotent, so a network retry never creates (or charges for) a second render.
  3. Tell the client when it's done, either by polling a job resource or by pushing a signed webhook.

Step 1: Authenticate

VlogMe uses a bearer token that you create in Settings → API (it's shown once, so store it in a secret manager or at least an env var):

export VLOGME_TOKEN=vlm_live_xxxxxxxxxxxxxxxx
Enter fullscreen mode Exit fullscreen mode

Every request sends it as Authorization: Bearer $VLOGME_TOKEN. The base URL is https://vlogme.ai/api/v2.

Step 2: Start a render (and make it idempotent)

curl -X POST https://vlogme.ai/api/v2/renders \
  -H "Authorization: Bearer $VLOGME_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: video-request-001" \
  -d '{
    "project_id": "PROJECT_UUID",
    "revision_id": "REVISION_UUID",
    "preset": "balanced",
    "idempotency_key": "video-request-001"
  }'
Enter fullscreen mode Exit fullscreen mode

The API answers 202 Accepted right away, with a body that includes the job id, its status, credits_charged, estimated_seconds and any warnings.

Why the idempotency key matters: paid generation is exactly the kind of request you don't want to run twice. If your process crashes or the connection drops after sending the request, you can safely retry with the same key and get the original job back instead of a duplicate render. Generate the key from something stable in your own system (an order id, a content id + version), not a random value per attempt, or the protection disappears.

Step 3a: Poll the job

The simplest option is to poll every ~10 seconds:

import os, time, requests

BASE = "https://vlogme.ai/api/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['VLOGME_TOKEN']}"}

def wait_for_job(job_id, interval=10, timeout=900):
    deadline = time.time() + timeout
    while time.time() < deadline:
        r = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS, timeout=30)

        if r.status_code == 429:  # rate limited: honor Retry-After
            time.sleep(int(r.headers.get("Retry-After", interval)))
            continue
        r.raise_for_status()

        job = r.json()
        # check the docs for the exact terminal status names
        if job["status"] in ("completed", "failed", "canceled"):
            return job
        time.sleep(interval)
    raise TimeoutError(f"job {job_id} did not finish in {timeout}s")
Enter fullscreen mode Exit fullscreen mode

Two details worth copying into any polling client:

  • Respect Retry-After on 429s. VlogMe rate-limits per user and exposes X-RateLimit-* headers on successful responses, so a well-behaved client can slow down before it gets throttled.
  • Always have a timeout. A job that never finishes should surface as an error, not a hung worker.

Polling is fine for scripts and low volume. For production, webhooks are cheaper and faster.

Step 3b: Receive a signed webhook instead

Pass a webhook_url when you create the render and the API calls you when the job changes state. The catch with webhooks is that anyone can POST to your URL, so you must verify the request actually came from the API.

VlogMe signs each delivery with three headers:

  • X-Vlogme-Signature: sha256= + HMAC-SHA256 of timestamp + "." + raw_body, using your webhook secret (whsec_..., from Settings → API)
  • X-Vlogme-Timestamp: Unix seconds
  • X-Vlogme-Event-Id: a unique id, useful for deduplication

Here's a complete Flask receiver:

import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["VLOGME_WEBHOOK_SECRET"].encode()  # whsec_...
seen_events = set()  # use Redis/DB in production

@app.post("/webhooks/vlogme")
def vlogme_webhook():
    raw = request.get_data()  # raw bytes, NOT re-serialized JSON
    ts = request.headers.get("X-Vlogme-Timestamp", "")
    sig = request.headers.get("X-Vlogme-Signature", "")
    event_id = request.headers.get("X-Vlogme-Event-Id", "")

    # 1. reject old deliveries (replay protection)
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(400)

    # 2. verify the signature in constant time
    expected = "sha256=" + hmac.new(
        SECRET, ts.encode() + b"." + raw, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401)

    # 3. deduplicate retries
    if event_id in seen_events:
        return "", 200
    seen_events.add(event_id)

    # 4. acknowledge fast, do the real work in a queue
    enqueue_job_update(request.get_json())
    return "", 200
Enter fullscreen mode Exit fullscreen mode

The mistakes I see most often with webhook receivers, all avoided above:

  • Signing the parsed JSON instead of the raw body. Re-serializing changes whitespace/key order and the signature never matches. Always HMAC the raw bytes.
  • Comparing signatures with ==. Use hmac.compare_digest to avoid timing attacks.
  • No timestamp check. Without it, a captured request can be replayed forever. Five minutes is a common window.
  • Doing slow work inside the handler. VlogMe expects a 2xx within 5 seconds; 5xx or timeouts are retried with backoff, and 4xx is treated as a permanent rejection. So acknowledge quickly and push the work to a queue, and dedupe on the event id because retries will happen.

Handling errors properly

Good async APIs return machine-readable error codes, not just status numbers. The ones worth handling explicitly here:

Code HTTP What to do
missing_token / invalid_token / token_expired 401 Stop and alert, retrying won't help
insufficient_credits 402 Response includes needed and balance, so show the user exactly what's missing
invalid_input / invalid_asset 400 Fix the payload, don't retry as-is
rate_limited 429 Wait for Retry-After, then retry
internal_error 500 Retry with backoff (safe because of the idempotency key)

Every response also carries an X-Request-Id. Log it next to your own job id; when something goes wrong, it's the one value support will ask for.

Bonus: letting an AI agent drive the API over MCP

If you'd rather have Claude, Codex or another agent create videos for you, the same API is exposed as an MCP server. With Codex CLI:

export VLOGME_TOKEN=vlm_live_xxxxxxxxxxxx
codex mcp add vlogme --url https://mcp.vlogme.ai/api/mcp --bearer-token-env-var VLOGME_TOKEN
Enter fullscreen mode Exit fullscreen mode

The server exposes tools like list_voices, get_balance, estimate_credits, generate_video, get_video and cancel_video. I like having estimate_credits available to agents in particular: you can instruct the agent to estimate first and ask before spending, which puts a human checkpoint in front of anything that costs money.

Takeaways

  • Long-running generation should return 202 + a job id, never block.
  • Idempotency keys make retries safe, and that matters most on paid requests.
  • Poll for simplicity, and use signed webhooks for production, verified on the raw body with a timestamp window and deduped on event id.
  • Treat error codes as part of your API contract, not just HTTP status.

Full reference (including an OpenAPI 3.1 spec) is in the VlogMe API docs. If you've built webhook receivers for other generation APIs, I'd love to hear what edge cases bit you, so drop them in the comments.

Top comments (0)