Async Jobs and Polling: Production Video Pipelines
AI video generation is not a request/response operation. It is a job you start, a state machine you track, and a result you collect minutes later. Almost every broken video integration is broken because it was written as though the render were instant.
Why This Is the Hardest Part
The PhotoAIVideo API has eight endpoints and only two of them cost credits. Reading the reference takes fifteen minutes. Yet integrations still fail in production, and the reason is almost never the endpoint contract — it is the assumption that a render returns a video.
It does not. POST /api/v1/videos returns a jobId and a status of pending. The actual cinematic clip arrives two to four minutes later. Reels take longer, because a reel cannot start until every clip feeding it has already completed. If your architecture cannot tolerate a multi-minute gap between intent and result, it will fail under real traffic no matter how clean the request code looks.
This guide covers the four job states, the polling loop that survives production, the worker architecture that keeps a single bulk import from taking down your app, and the five mistakes that account for most support tickets. Every pattern here maps directly onto the endpoints documented in the API reference.
The Four Job States
Every video and reel job moves through the same small state machine. Two states are transient and two are terminal. The entire discipline of a correct integration is knowing which is which and never polling past a terminal state.
pendingThe job is accepted and queued. Credits are already reserved. Nothing to download yet.
What to do: Keep waiting. Do not resubmit.
processingThe render is actively running. This is where the job spends most of its life.
What to do: Keep polling on your interval.
completedThe render finished and output URLs are populated on the response.
What to do: Persist the URLs, then stop polling permanently.
failedThe render could not be produced. The credit is refunded automatically.
What to do: Log the reason, surface it, stop polling. Retrying blindly wastes credits.
Creating a Job Correctly
Start by reserving upload URLs, push your photos to them, then submit the render. The create call returns immediately — your job is to persist what it gives you before you do anything else.
# 1. Reserve signed upload URLs (free)
curl -X GET "https://app.photoaivideo.com/api/v1/presigned-urls?count=1" \
-H "X-API-Key: $PHOTOAIVIDEO_API_KEY"
# 2. Upload the photo to the signed URL you received (free)
curl -X PUT "<uploadUrl>" \
-H "Content-Type: image/jpeg" \
--data-binary "@living-room.jpg"
# 3. Start the render (1 credit) — returns instantly with a jobId
curl -X POST "https://app.photoaivideo.com/api/v1/videos" \
-H "X-API-Key: $PHOTOAIVIDEO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"imageUrl": "<publicUrl>",
"effect": "orbit_right",
"duration": 5
}'The response contains the jobId, a status of pending, and the credits charged. Write all three to your database in the same transaction that records which listing and which customer the job belongs to. If your process dies immediately after this call and you have not persisted the jobId, you have paid for a render you can never retrieve.
A Polling Loop That Survives Production
Polling is free, which tempts developers into hammering the status endpoint. Resist it. The correct shape is an initial delay, a steady interval, and a hard deadline that converts a hung job into an alert instead of an infinite loop.
const API = "https://app.photoaivideo.com/api/v1"
const HEADERS = { "X-API-Key": process.env.PHOTOAIVIDEO_API_KEY }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function waitForVideo(jobId, { firstDelay = 30000, interval = 10000, timeout = 1200000 } = {}) {
const deadline = Date.now() + timeout
await sleep(firstDelay) // a render never finishes in under 30s
while (Date.now() < deadline) {
const res = await fetch(`${API}/videos/${jobId}`, { headers: HEADERS })
if (res.status === 429) { // backed up — slow down, don't give up
await sleep(interval * 3)
continue
}
if (res.status >= 500) { // transient upstream issue
await sleep(interval * 2)
continue
}
if (!res.ok) {
throw new Error(`Unrecoverable status ${res.status} for job ${jobId}`)
}
const job = await res.json()
if (job.status === "completed") return job // terminal: collect and stop
if (job.status === "failed") { // terminal: credit auto-refunded
throw new Error(`Render failed for ${jobId}: ${job.error ?? "unknown reason"}`)
}
await sleep(interval) // pending or processing — keep going
}
throw new Error(`Job ${jobId} did not finish within the deadline`)
}Three details matter more than the rest. The initial delay eliminates a burst of guaranteed-useless requests. Treating 429 and 5xx as slow-down-and-continue rather than fail keeps a temporary hiccup from destroying a job you already paid for. And the deadline guarantees the loop terminates, which is what stops a worker pool from leaking until it deadlocks.
Where the Polling Should Live
The single most common architectural mistake is polling inside the HTTP request that created the job. A user clicks Generate, your handler starts the render, and then blocks for four minutes waiting for it. That request will die — serverless functions time out, load balancers close idle connections, and the user refreshes long before anything appears.
Split it in two. The request that creates the job returns the jobId instantly. A background worker owns the waiting. Your own frontend polls your own database, never the video API directly.
// Your API route — returns in milliseconds
export async function POST(req) {
const { listingId, imageUrl, customerId } = await req.json()
const created = await fetch(`${API}/videos`, {
method: "POST",
headers: { ...HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl, effect: "orbit_right", duration: 5 }),
}).then((r) => r.json())
// Persist before returning — this row is your source of truth
await db.videoJobs.insert({
jobId: created.jobId,
listingId,
customerId,
status: "pending",
creditsCharged: created.creditsCharged,
createdAt: new Date(),
})
await queue.enqueue("poll-video-job", { jobId: created.jobId })
return Response.json({ jobId: created.jobId, status: "pending" })
}
// Your worker — owns the waiting, and only writes terminal state once
async function pollVideoJob({ jobId }) {
try {
const job = await waitForVideo(jobId)
const stored = await copyToOwnStorage(job.videoUrl) // own the asset
await db.videoJobs.update(
{ jobId, status: ["pending", "processing"] }, // guard: write once
{ status: "completed", videoUrl: stored, completedAt: new Date() },
)
} catch (err) {
await db.videoJobs.update(
{ jobId, status: ["pending", "processing"] },
{ status: "failed", error: String(err), completedAt: new Date() },
)
}
}The conditional update is what makes the worker safe to run twice. If your queue delivers a message redundantly — and every queue eventually will — the second run finds the row already terminal and changes nothing. Without that guard, a duplicate delivery can overwrite a completed job or double-count a charge in your billing table.
If you prefer callbacks to polling
Some teams would rather be notified than ask repeatedly. Check the current API reference for callback support on your plan. Either way, keep the polling worker as a fallback: a missed or failed callback delivery should never leave a paid render permanently stranded in pending. Treat a notification as an optimization that shortcuts the wait, not as your only path to a terminal state.
Sequencing Clips Into a Reel
Reels introduce a dependency that trips up bulk pipelines. POST /api/v1/reels merges two to twenty clips, but every clip must already be completed before the merge can begin. That means a full listing is two waves of waiting, not one.
async function buildListingReel(photoUrls) {
// Wave 1: start every clip at once, then wait for all of them
const jobs = await Promise.all(
photoUrls.map((imageUrl) =>
fetch(`${API}/videos`, {
method: "POST",
headers: { ...HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl, effect: "orbit_right", duration: 5 }),
}).then((r) => r.json()),
),
)
const clips = await Promise.all(jobs.map((j) => waitForVideo(j.jobId)))
// Wave 2: only now can the merge start (1 additional credit)
const reel = await fetch(`${API}/reels`, {
method: "POST",
headers: { ...HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ videoJobIds: jobs.map((j) => j.jobId) }),
}).then((r) => r.json())
return waitForReel(reel.jobId)
}Note the cost shape: a ten-photo listing is ten credits for the clips plus one for the merge. Eleven credits, two waves, and a total wall-clock time driven by the slowest clip rather than the sum of all of them — which is exactly why you start them concurrently rather than in sequence. The credit mechanics behind this are covered in detail in our authentication and credits guide.
Five Mistakes and Their Fixes
Queue Rules Worth Enforcing
- Store every job in your own table the moment you create it, keyed by jobId, with its listing and customer attached.
- Let a background worker own polling — never the web request that created the job.
- Make the worker idempotent so processing the same job twice cannot double-charge or duplicate a record.
- Record a pollCount and lastPolledAt so a stuck job is visible in a query instead of a log grep.
- Cap concurrent in-flight jobs per customer so one bulk import cannot starve every other tenant.
- Write terminal results exactly once, using a conditional update guarded on the current state.
Rate Limits and Backpressure
A 429 is not an error condition, it is a scheduling signal. When you receive one, the right response is to slow down and continue, because the job you are polling is still running and you have already paid for it. Abandoning the poll on a 429 throws away a credit for no reason.
Backpressure matters just as much on the create side. If a brokerage imports four hundred listings at once, submitting every render simultaneously will exhaust your plan's credits in minutes and monopolize throughput that your interactive users need. Cap concurrent in-flight jobs per tenant, keep a small reserve of credits for real-time requests, and drain the bulk queue at a steady rate instead of all at once.
Check your balance before a batch, not during
GET /api/v1/credits is free. Call it before you enqueue a large import and refuse to start unless the remaining balance covers every clip plus every merge. Running out mid-batch leaves you with a pile of orphaned clips that each cost a credit and cannot be assembled into anything a customer would pay for.
What Good Looks Like
A healthy pipeline has a few observable properties. Every job in your database has a jobId, an owner, and a state. No job sits in processing for longer than your deadline without triggering an alert. Finished videos live in your own storage rather than being re-fetched from a URL you do not control. Failed jobs carry a readable reason your support team can act on. And your credit ledger reconciles against the balance the API reports.
None of that requires exotic infrastructure. A jobs table, one worker, a conditional update, and a bounded polling loop are enough to run thousands of listings a month reliably. The teams that struggle are almost always the ones that skipped the jobs table and tried to keep state in a request.
Get an API key and run your first job in an afternoon
Eight endpoints, two of which cost credits. Plans start at $99/month for 250 credits, and status polling is always free — so you can build and test your worker without burning through your balance.
Frequently Asked Questions
How long does a render actually take?
Plan for two to four minutes per clip. Reels take longer because they cannot start until every input clip has completed. Build your UI around a progress state rather than a spinner that implies imminence.
Does polling cost credits?
No. Only POST /api/v1/videos and POST /api/v1/reels are billable. Status checks, listings, and balance reads are all free — which is why a sane interval costs you nothing but a fast loop still wastes capacity.
What happens to my credit if a render fails?
It is refunded automatically when the job reaches failed. You do not need to open a ticket. Do mark the refund in your own ledger so your internal accounting matches the reported balance.
Should I retry a failed job automatically?
Only after inspecting the reason. Deterministic failures such as an unreachable image URL will fail identically on every attempt, and each one costs a credit before the refund posts. Fix the input, then resubmit.
Can I run this without a queue system?
For low volume, a scheduled task that scans your jobs table for non-terminal rows and polls each one works fine. The essential requirement is not a specific queue product — it is that the polling happens outside the request lifecycle and that job state lives in your database.
Related Articles
Bulk Listing Video Generation at Scale
Concurrency caps, batch budgeting, and draining a large import safely.
API Keys, Authentication and Credits
Which endpoints bill, how refunds work, and how to meter per customer.
PhotoAIVideo Developer API
Endpoint reference, camera effects, credit costs, and plan tiers.