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
Three ideas carry the whole design:
- Return 202 immediately with a job id instead of waiting for the result.
- Make the create call idempotent, so a network retry never creates (or charges for) a second render.
- 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
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"
}'
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")
Two details worth copying into any polling client:
-
Respect
Retry-Afteron 429s. VlogMe rate-limits per user and exposesX-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 oftimestamp + "." + 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
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
==. Usehmac.compare_digestto 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
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)