xAI video API polling: pending, done, failed vs Sume job states

xAI's video API returns a request_id and three states. Here is how they map onto Sume's five /v1/videos statuses, with a runnable Python polling loop.

5 min readSume
All posts

xAI's video endpoint is a poll-only, two-call flow: POST /v1/videos/generations returns a request_id, and GET /v1/videos/{request_id} answers pending, done, or failed. Sume's POST /v1/videos has the same shape with five statuses (pending, in_progress, completed, failed, cancelled), so an xAI polling loop moves over by renaming three strings and handling one extra state.

The xAI side comes from Video Generation: Grok API Capabilities (read 2026-10-11); the Sume side from Video Generation and Jobs and results. The xAI page describes polling only and does not mention a callback or an idempotency header, so this post does not assume either exists there. Whether a given xAI model is on Sume is a catalog question: ask GET /v1/videos/models instead of guessing.

How do xAI's three states map onto Sume's five?

Collapse the two in-flight Sume states into one and add a branch for cancelled, which has no xAI counterpart on the page I read.

On the failed branch, do not stop at the word. Sume's errors page says a failed job carries public error metadata such as category, stage, retryability, retry-after seconds, public reason and next action. Read those fields before deciding whether to resubmit, because a validation failure needs a corrected request while a queue failure is worth retrying later with the same idempotency key.

xAI states from xAI's video generation docs (read 2026-10-11); Sume states from docs.sume.com/models/videos.
xAI statusSume /v1/videos statusWhat your loop does
pendingpending or in_progressSleep and poll again.
donecompletedDownload from unsigned_urls, or from GET /v1/videos/{id}/content?index=0.
failedfailedRead the error field of the poll response.
(none)cancelledStop. The job was canceled and never completed; this is not a retry signal.

What else differs besides the status names?

Four things matter when you port the loop, and only one of them is a rename.

  • Duration: xAI documents 1 to 15 seconds with a default of 8. Sume does not use one global range; each model lists supported_durations in GET /v1/videos/models, and the limits differ by model (Seedance 2.5 takes 4 to 30 seconds, Gemini Omni Flash 1.1 takes 3 to 10).
  • Polling cadence: xAI says its SDKs poll every 1 second (Python SDK) or 5 seconds (AI SDK) by default. Sume's video docs suggest a moderate interval such as 30 seconds, and GET /v1/jobs/{id}/status can return next_poll_after_seconds, which wins when present.
  • Retries: Sume accepts an Idempotency-Key on /v1/videos, and a replay returns the original job, so a lost response does not buy a second clip.
  • Output links: xAI says videos come back as temporary URLs. Sume's docs show unsigned_urls pointing at the /content route of the job, and the documented curl download sends the API key header.

What does the ported loop look like?

This is stdlib Python. It keeps xAI-style states (pending, done, failed) at the edge of your own code so the rest of an existing pipeline does not change. The mapping table is your adapter, not a Sume feature. It sets an explicit User-Agent and passes the idempotency key on the submit call.

import json, os, time, urllib.request

KEY = os.environ["SUME_API_KEY"]
BASE = os.environ.get("SUME_BASE", "https://api.sume.com")
STATE = {"pending": "pending", "in_progress": "pending",
         "completed": "done", "failed": "failed", "cancelled": "failed"}

def call(method, path, body=None, key=None):
    headers = {"Authorization": f"Bearer {KEY}",
               "Content-Type": "application/json",
               "User-Agent": "xai-port/1.0"}
    if key:
        headers["Idempotency-Key"] = key
    data = json.dumps(body).encode() if body else None
    req = urllib.request.Request(BASE + path, data, headers, method=method)
    with urllib.request.urlopen(req, timeout=60) as resp:
        return json.load(resp)

def start(body, idempotency_key):
    return call("POST", "/v1/videos", body, idempotency_key)["id"]

def poll(request_id, every=30, deadline=1200):
    end = time.time() + deadline
    while time.time() < end:
        job = call("GET", f"/v1/videos/{request_id}")
        if STATE[job["status"]] != "pending":
            return STATE[job["status"]], job
        time.sleep(every)
    raise TimeoutError(request_id)  # the job keeps running and billing

Should the loop poll `/v1/videos` or `/v1/jobs`?

Both read the same job. The /v1/videos/{id} route keeps the OpenRouter-compatible fields (polling_url, unsigned_urls, usage), while GET /v1/jobs/{id}/status returns the terminal and result_ready booleans that the jobs docs tell you to poll on. If your code already speaks xAI's three states, stay on /v1/videos; if you are building something new, the booleans are harder to misread.

Two rules carry over from the jobs docs either way. A client-side timeout does not cancel the job, which keeps running and billing, so store the id and read it again later. And do not submit the original paid request again just because a local process timed out; retry the submit only with the same Idempotency-Key.

If you would rather not poll at all, send callback_url on the submit. Sume then POSTs its standard job envelope (job.completed, job.failed or job.canceled), signed with x-sume-webhook-signature. Keep a status poll as the backup for deliveries that never arrive.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume