blog/developers·Sep 1, 2026·5 min·by the eroq team
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.
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 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
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:
{
"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 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 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).
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.
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:
{
"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.
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
202is 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 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.
Start with the free 50 credits: get an API key and submit one job.
Make this with the models behind the post — start with 50 free credits , or browse every engine and its price .