Skip to main content

Polling and job status

Generation is asynchronous. Every endpoint that starts work returns immediately; you find out what happened by polling.

curl -s https://api.botlobby.ai/v1/gen/task-status/CONTENT_ID \
-H "Authorization: Bearer $BOTLOBBY_API_KEY"
{
"content_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "PROCESSING_VIDEO",
"metadata": { "video_created_at": null }
}

Scope: read. This endpoint is not rate limited, so polling never eats into your generation allowance.

The status table

StatusPhaseTerminal?
NEWCreated, nothing startedno
PROCESSINGScript generationno
PROCESSING_AUDIOVoiceover runningno
AUDIO_PROCESSEDVoiceover done, ready to renderno
PROCESSING_VIDEORender runningno
VIDEO_PROCESSEDRendered, not publishedno
UPLOADINGPublish dispatchedno
UPLOADEDPublishedyes
FAILED_AUDIOVoiceover failedyes
FAILED_VIDEORender failedyes
FAILED_UPLOADPublish failedyes
FAILEDGeneric failureyes

Stop polling on any terminal status.

Two states that need explaining

VIDEO_PROCESSED is terminal for a render-only job. It means the video exists and nothing is publishing it — either you never asked, or no accounts were selected. If you expected publication and landed here permanently, check that your account_ids were shaped correctly — see the publishing warning.

READY_FOR_UPLOAD is deprecated. It was split into UPLOADING (dispatched) and VIDEO_PROCESSED (parked). It only appears on rows written before that change. Treat it as non-terminal if you meet it on old content.

Reading failures

On any FAILED_*, metadata.last_error carries the reason:

{
"content_id": "3fa85f64-...",
"status": "FAILED_VIDEO",
"metadata": { "last_error": "Render exceeded the maximum duration" }
}

The same field appears as last_error on GET /contents/{id}.

Credits are refunded on failure, so a failed job costs nothing — you do not need to reconcile anything.

A polling loop

Poll every few seconds with a ceiling. Not a tight loop, and not forever:

import time
import requests

BASE = "https://api.botlobby.ai/v1"
TERMINAL = {
"UPLOADED",
"FAILED_AUDIO",
"FAILED_VIDEO",
"FAILED_UPLOAD",
"FAILED",
}


def wait_for(content_id, api_key, until, timeout=1800, interval=5):
"""Poll until `until` is reached, a terminal status lands, or we time out.

`until` lets you stop at an intermediate state - AUDIO_PROCESSED before
rendering, VIDEO_PROCESSED for a render-only job - which is why terminal
statuses alone are not enough to stop on.
"""
headers = {"Authorization": f"Bearer {api_key}"}
deadline = time.monotonic() + timeout

while time.monotonic() < deadline:
response = requests.get(
f"{BASE}/gen/task-status/{content_id}",
headers=headers,
timeout=30,
)
response.raise_for_status()
body = response.json()
status = body["status"]

if status == until:
return body

if status in TERMINAL:
reason = (body.get("metadata") or {}).get("last_error", "no reason given")
raise RuntimeError(f"{content_id} ended as {status}: {reason}")

time.sleep(interval)

raise TimeoutError(f"{content_id} still {status} after {timeout}s")

Used across the stepwise flow:

wait_for(content_id, key, until="AUDIO_PROCESSED")
# ... POST /gen/video ...
wait_for(content_id, key, until="VIDEO_PROCESSED")

Practical notes

  • Five seconds is a reasonable interval. Renders take minutes, not milliseconds; polling faster gains you nothing.
  • Always set a timeout. A job that never reaches a terminal status should fail your process loudly rather than hang it.
  • Don't poll to decide whether to retry a publish. Poll to find out what happened — a 409 on upload means a dispatch is already in flight, and retrying into it is what that guard exists to prevent. See Errors.
  • video_url is not the completion signal. It is populated once rendered but is a signed, time-limited URL. Use the status for control flow and fetch the URL fresh when you actually need the file.