# Async video generation API — jobs, polling and signed webhooks

> How to integrate an async video generation API without losing money or clips. Submit a job, poll or take a signed webhook, handle refunds and retries safely.

Published 2026-09-01 · eroq.ai — canonical: https://eroq.ai/blog/async-video-generation-api-webhooks


A video render takes one to five minutes. No HTTP request should stay open that long — load balancers time out, mobile radios drop, and your users hit refresh. So every serious video generation API is asynchronous: you submit a job, you get an id, and you find out later. The details of "later" are where integrations lose money and lose clips. This is how the eroq [video endpoint](/docs/video) is shaped and how to consume it correctly.

## The contract in one paragraph

`POST /v1/videos/generations` charges the flat credit price, starts the render, and answers `202` with a job id. You then either poll `GET /v1/videos/generations/{id}` (free, every few seconds) or receive a signed webhook when the job finishes. A render that fails — or that never completes within 10 minutes — refunds itself; a prompt outside the acceptable-use policy returns `content_blocked` and is never charged. You only pay for a delivered clip. Job ids live 24 hours.

## Submit a job

```bash
curl https://eroq.ai/v1/videos/generations \
  -H "Authorization: Bearer $EROQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "eroq-motion-one",
    "prompt": "Slow push-in on a lighthouse keeper in a yellow raincoat climbing a spiral stone staircase at night, lantern swinging, warm practical light against cold blue storm windows, rain streaking the glass, tense and patient mood, 35mm film grain.",
    "seconds": 10,
    "aspect": "9:16",
    "store": true
  }'
```

The response:

```json
{
  "id": "b7e6c2d4-…",
  "status": "processing",
  "usage": { "credits_spent": 180, "credits_remaining": 887 }
}
```

Two parameters deserve a note.

`seconds` is the clip length, 1 to 30. Your plan caps it — 10 s pay-as-you-go, 15 on Hobby, 20 on Creator, 30 on Studio and above — and the engine's grid snaps it: [Motion One](/models/eroq-motion-one) renders 5 or 10 s, Seedance 2.5 anything from 4 to 30. The snap happens before billing, so the price always matches the clip that renders. Send `seconds: 8` to Motion One and it snaps to the engine's grid and bills the clip it actually makes; check `/v1/engines` if you want the grid up front.

`aspect` takes `16:9`, `9:16`, `1:1`, `21:9`, `4:3` or `3:4`. Where the engine accepts a ratio natively it is passed through; elsewhere it is folded into the prompt — a strong request to the engine, not a hard guarantee. The [aspect ratio](/glossary/aspect-ratio) entry has the per-engine detail.

`store: true` is optional and worth it in production: the finished clip goes to the eroq Store and the job carries a durable CDN URL instead of inline base64 that expires with the job. It adds 2 credits per started 10 MB ([pricing](/pricing)).

## Option A — poll

Polling is free and the intended cadence is every few seconds. Keep a deadline slightly past the 10-minute refund window so a stuck job resolves itself before your loop gives up.

```js
const BASE = 'https://eroq.ai/v1'
const headers = { Authorization: `Bearer ${process.env.EROQ_API_KEY}` }

async function waitForClip(jobId, { every = 4000, deadline = 11 * 60_000 } = {}) {
  const started = Date.now()
  while (Date.now() - started < deadline) {
    const job = await fetch(`${BASE}/videos/generations/${jobId}`, { headers }).then(r => r.json())
    if (job.status === 'succeeded') return job.data[0].url   // with store: true
    if (job.status === 'failed') throw new Error(job.error?.code ?? 'generation_failed')
    await new Promise(r => setTimeout(r, every))
  }
  throw new Error('timeout')
}
```

`status` walks `processing` to `succeeded` or `failed`. On `failed` the credits are already back in the wallet, so "retry" means "submit again", not "argue about the bill."

## Option B — signed webhook

Register an endpoint in the developers dashboard, subscribe it to `video.generation.succeeded` and `video.generation.failed`, and copy the `whsec_` secret — it is shown once. Each delivery is a JSON POST:

```json
{
  "id": "evt_9f1c…",
  "type": "video.generation.succeeded",
  "data": {
    "id": "b7e6c2d4-…",
    "result_url": "https://…/b7e6c2d4.mp4"
  }
}
```

A failed job carries an `error` object instead of a `result_url`. Media never travels in the payload; you get a URL or you fetch the job.

The signature is Stripe-style, in the `eroq-signature` header: `t=<unix timestamp>,v1=<hex>`, where `v1` is HMAC-SHA256 of `${t}.${rawBody}` with your secret. Verify against the raw bytes — parse JSON only after the check.

```js
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
  const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < toleranceSec
  const a = Buffer.from(expected)
  const b = Buffer.from(String(parts.v1 ?? ''))
  return fresh && a.length === b.length && timingSafeEqual(a, b)
}
```

Delivery is one attempt with a 10-second budget, and a customer's down server never fails a customer's render. So: answer `2xx` immediately, do the work afterwards, and keep polling as the fallback for anything you did not hear about. The 24-hour queue list at `GET /v1/videos/generations` exists for exactly that reconciliation.

## Idempotency — the part that actually loses money

Every failure mode here is a duplicate: a webhook that arrives after your poller already saw success, a retry after a network error on submit, a worker that crashes between the `202` and the database write.

- **Persist the job id in the same transaction as the user-facing state.** The `202` is the moment you own a charge; if that id is not on disk before you tell the user "rendering", a crash costs you a clip you cannot find. Reconcile with the queue list on restart.
- **Upsert by job id, and dedupe events by `evt_` id.** Both channels can report the same completion. The first one wins, the second is a no-op.
- **Never re-submit on a submit-side network error without looking.** The request may have gone through. Check the queue list for a job with the same prompt in the last minute before charging yourself twice.
- **Only retry on `failed`.** The refund has already happened, so a fresh submit is a fresh single charge. Respect the per-key rate limit — video is 6 requests a minute on the base tier, multiplied by your plan.

Flat [credit pricing](/blog/credit-pricing-for-ai-apis) makes all of this easy to audit: every job maps to one known charge, and the `usage` block on the response tells you the balance at that moment.

## FAQ

### How long do I have to fetch the clip?

Job ids expire 24 hours after creation, and inline base64 goes with them. Use `store: true` for a CDN URL that stays, or download on completion.

### What if my webhook endpoint is down?

One attempt, then nothing. Poll `GET /v1/videos/generations/{id}` or list the last 24 hours to catch up. Webhooks are a convenience; polling is the source of truth.

### Can I get a soundtrack?

Only on `veo-3-fast`, which renders native ambience, effects and dialogue on fixed 8-second clips; it is SFW only and needs a Creator plan. Every other engine renders silent video — see the [engine roster](/models).

Start with the free 50 credits: [get an API key](/signup) and submit one job.
