# Scaling AI video generation in production — queues and retries

> How to run AI video at volume — async jobs, your own durable queue, concurrency against rate limits, refunds on failure, key fleets and signed webhooks.

Published 2026-09-03 · eroq.ai — canonical: https://eroq.ai/blog/scaling-ai-video-generation-in-production


One clip is a demo. A thousand clips a week is an operations problem, and the parts that break are never the ones in the model card. They are the process that restarted mid-render, the customer who was charged for a job that failed, and the batch that swallowed the rate limit your live product needed.

This is what a video pipeline looks like when it survives volume. The shapes are eroq's, documented on [/docs/video](/docs/video), but the reasoning ports to any async media API.

## Submit, don't wait

A video render takes one to five minutes. No HTTP request should stay open that long, so the endpoint is asynchronous: the call charges, starts the render, and answers `202` immediately.

```json
{
  "id": "b7e6c2d4-…",
  "object": "video.generation",
  "status": "processing",
  "created": 1756118400,
  "model": "eroq-motion-one",
  "duration": "5s",
  "poll": "/v1/videos/generations/b7e6c2d4-…",
  "usage": { "credits_spent": 100, "credits_remaining": 887 }
}
```

**Persist that id before you do anything else** — before you return to the caller, before you log. The job id is the only handle on a render you have already paid for, and everything below assumes it is in your database rather than in a variable on a machine that might restart.

## A queue of your own

You need one, and it should not be the API's queue.

Demand from your users is bursty; render capacity is not. Without a buffer you have two bad options — reject work at peak, or fan out parallel requests until the rate limiter refuses them. A queue turns both into a scheduling decision.

The minimum viable table has five columns that matter:

- **job id** from the submit response, and **status** as you last observed it
- **the request payload**, so a resubmission is exactly the same render
- **attempt count**, so a poison job stops after three tries instead of forever
- **the customer or campaign it belongs to**, so cost lands on the right account
- **credits spent**, copied from `usage.credits_spent` at submission

Then a worker that drains the queue at a fixed rate, and a second loop polling `GET /v1/videos/generations/{id}` for anything still processing.

For recovery after a crash there is a list endpoint. `GET /v1/videos/generations` returns every job the *workspace* submitted in the last 24 hours — teammates' renders included — with `limit` and a `status` filter. It never carries inline media, so it is cheap to loop over.

```bash
# what is still in flight right now
curl "https://eroq.ai/v1/videos/generations?status=processing&limit=50" \
  -H "Authorization: Bearer $EROQ_API_KEY"
```

If your database and that list disagree, the list is right.

## Sizing concurrency against the rate limits

Two different numbers, and conflating them is the usual mistake.

**Submission rate** is bounded by the API — 6 video requests per minute per key, multiplied by your plan (2× Creator, 4× Studio, 6× Team, 8× Agency). Model it with a token bucket in your worker.

**In-flight renders** is bounded by nothing but your patience and your wallet. Cap it explicitly anyway — an in-flight ceiling is what stops a runaway retry loop from spending a month of credits in an afternoon, and it gives you a number to alert on.

Set the bucket just under the effective limit and let the queue absorb the difference — handling a 429 matters less when you rarely generate one.

## Retries, refunds and what not to retry

The money model makes retrying safe: **credits are charged at submission, and a render that fails — or never completes within ten minutes — refunds itself automatically.** You reconcile delivered clips, not attempts.

Two more guarantees. A request the content policy blocks returns `content_blocked` and is never charged. And the gate checks happen *before* the debit — a model your plan does not include answers `403 plan_required`, an engine that is not currently servable answers `503 model_unavailable`, both before any credits move, and neither falls back silently to a different engine. You get the model you asked for or an error — the only behavior that keeps your output consistent with your logs.

So the retry policy writes itself:

- **Retry with backoff** — `429`, and network-level failures where you never saw a response.
- **Retry once, then switch engine** — `503 model_unavailable`. Keep a fallback engine id in config, chosen by you, from the roster on [/models](/models).
- **Do not retry, surface it** — `402 insufficient_credits`, `403 plan_required`, `403 role_forbidden`, and any `content_blocked` refusal. None of these change on their own within a retry window.
- **Do not auto-retry a failed render more than twice.** A prompt that fails twice usually fails a third time, and refunds mean the cost is latency — which is what your customer is watching.

A resubmit is a *new* job with a new id, not a resurrection of the old one — link both ids in your table or your per-customer cost accounting will drift.

## A key fleet, not a key

Rate limits are counted per key, so keys are the natural blast-radius boundary. Plans include a fleet for exactly this — 10 keys on Hobby, 20 on Creator, 50 on Studio, 100 on Team, 200 on Agency.

A sane split:

- one key per environment (production, staging, local),
- one key per pipeline in production — the interactive path users wait on, the overnight batch path, the internal tooling path,
- and, if you resell generation, one per large customer, so their usage is legible in the request log.

Keys also inherit the workspace role of the member who holds them, so an offboarding is one role change rather than a key audit. The Members tab is at [/dashboard/workspace](/dashboard/workspace); the per-customer cost arithmetic is in [credit pricing for AI APIs](/blog/credit-pricing-for-ai-apis).

## Webhooks instead of polling

Polling is fine at ten renders an hour and wasteful at a thousand. Register an endpoint and take `video.generation.succeeded` and `video.generation.failed` instead.

The payload is a small signed envelope — no megabytes of base64 crossing your load balancer:

```json
{
  "id": "evt_9f21…",
  "type": "video.generation.succeeded",
  "created": 1756118400,
  "data": {
    "id": "b7e6c2d4-…",
    "model": "eroq-motion-one",
    "duration": "5s",
    "content_type": "video/mp4",
    "result_url": "https://store.eroq.ai/…/clip.mp4"
  }
}
```

Delivery carries a Stripe-style `eroq-signature` header shaped `t=<unix>,v1=<hex>`, where the hex is an HMAC-SHA256 of `timestamp.body` under the endpoint secret shown once at creation. Verify it with a constant-time comparison before you trust a byte, and reject timestamps outside a few minutes to kill replays.

Three handler rules that save incidents:

1. **Respond 2xx fast, work later.** Acknowledge, enqueue, return. A handler that renders a thumbnail inline will eventually time out and make delivery look failed.
2. **Treat delivery as at-least-once.** Key your processing on the job id and make it idempotent.
3. **Keep polling as a floor.** A sweep over jobs your database still thinks are processing catches anything a webhook missed. And `result_url` is `null` when a clip came back inline rather than stored, so handle that branch.

## Batches — let the sequence be a resource

If you render sequences rather than one-offs, model the sequence server-side instead of orchestrating twelve submissions. eroq's `films` resource stores a storyboard, and `POST /v1/films/{id}/render` shoots every scene in one call: scenes are billed individually, the queue is sequential so an empty wallet stops the run cleanly, and the response reports job-or-error per scene. Details on [/docs/films](/docs/films).

Test the pipeline on a cheap engine before an expensive one — [Seedance 1.0 Lite](/models/seedance-1-lite) at 60 credits for five seconds makes an end-to-end dry run affordable, and your queue cannot tell the difference. Plan gates are on [/pricing](/pricing).

## FAQ

### Do I pay for AI video renders that fail?

No. Credits are charged at submission and a render that fails, or that never completes within ten minutes, refunds itself automatically. Requests blocked by the content policy return `content_blocked` and are never charged.

### How do I recover video jobs after my server restarts?

Call `GET /v1/videos/generations`, which lists every job the workspace submitted in the last 24 hours with its status. Reconcile against your own table and treat the API's list as the source of truth.

### Should I poll or use webhooks for video generation?

Poll while you are building and at low volume, then move to `video.generation.succeeded` and `video.generation.failed` webhooks when you render constantly. Keep a slow polling sweep as a safety net either way.

Build the queue first, then the pipeline — the endpoint reference is on [/docs/video](/docs/video).
