Generate a video with Python: tasks, retries & billing
Submit a Wan video through Jevrouter, save its task ID, safely resume polling and download the result. Includes Python and cURL examples with a spending limit.
By Jevrouter · Updated
Prepare your key and budget
Create a project API key that allows your selected video model, and add available project balance. Load the key from JEVROUTER_API_KEY on your server. The example uses Python 3.10 or later and only its standard library.
This script requests a real, billable five-second Wan 2.2 video. The October 7 example reserves up to $0.05; our production test settled at $0.035. Quotes change, so the spending limit can reject a future request. The limit caps the reservation, not just the estimated final charge.
export JEVROUTER_API_KEY='YOUR_PROJECT_API_KEY'
# Save the Python example below as generate_video.py
python generate_video.pySubmit once, resume and save the result
The script writes video-task.json before submitting. Keep this file if the connection fails or the process stops. Running the script again reuses the saved request body and idempotency key, or resumes the saved task ID. It does not start another video just because you ran it twice.
Use one process per state file. For a deliberately new video, set JEV_VIDEO_STATE to a new filename. Editing the example body while reusing an existing state file will not change the saved job. Protect these files: they contain your prompt and may contain private media URLs, but do not store your API key.
The worker continues independently when the client disconnects. The script waits five seconds between checks and stops after fifteen minutes without cancelling the task; resume with the same file. A completed task returns media URLs or structured output objects. The Wan example saves the first URL to video.mp4 without sending your API key to the media host.
import json
import os
import shutil
import time
import uuid
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlsplit
from urllib.request import Request, urlopen
API = "https://api.jevrouter.io/v1/videos"
KEY = os.environ["JEVROUTER_API_KEY"]
STATE = Path(os.environ.get("JEV_VIDEO_STATE", "video-task.json"))
# One state file per intended video. Keep it when retrying or resuming.
# Run only one copy of this script against a state file at a time.
if not STATE.exists():
with STATE.open("x") as file:
json.dump({"idempotency_key": str(uuid.uuid4()), "body": {
"model": "wan/wan-2.2-t2v-480p-ultra-fast",
"input": {
"prompt": "A yellow sailboat on a quiet lake",
"duration": 5,
"size": "832*480"
},
"jev": {
"max_cost_usd": "0.05"
}
}}, file)
state = json.loads(STATE.read_text())
def save():
temporary = STATE.with_name(STATE.name + ".tmp")
temporary.write_text(json.dumps(state))
temporary.replace(STATE)
def api(url, body=None):
headers = {"Authorization": "Bearer " + KEY}
if body is not None:
headers.update({"Content-Type": "application/json",
"Idempotency-Key": state["idempotency_key"]})
request = Request(url, data=json.dumps(body).encode() if body is not None else None,
headers=headers, method="POST" if body is not None else "GET")
try:
with urlopen(request, timeout=90) as response:
return response.status, json.load(response)
except HTTPError as error:
return error.code, json.loads(error.read())
except (URLError, TimeoutError):
raise SystemExit("Connection interrupted. Keep the state file and run again to resume.")
if not state.get("request_id"):
status, result = api(API, state["body"])
if status == 200:
state["request_id"] = result["data"]["id"]
else:
error = result.get("error", {})
recoverable = status in (409, 503) and error.get("code") in (
"request_already_exists", "outcome_unknown")
request_id = (error.get("details") or {}).get("request_id") or error.get("request_id")
if not recoverable or not request_id:
raise SystemExit(result)
state["request_id"] = request_id
save()
task_url = API + "/" + quote(state["request_id"], safe="")
print("Task:", state["request_id"])
deadline = time.monotonic() + 900
while time.monotonic() < deadline:
time.sleep(5)
status, result = api(task_url)
error = result.get("error", {})
if status == 429 or (status in (409, 503) and error.get("code") in (
"request_already_exists", "outcome_unknown")):
continue # Inspect the same task; never submit a replacement.
if status != 200:
raise SystemExit(result)
task = result["data"]
if task["status"] == "failed":
raise SystemExit(task.get("error") or "Video generation failed")
if task["status"] != "completed":
continue
print("Outputs:", task["outputs"])
outputs = task["outputs"]
if outputs and isinstance(outputs[0], str):
media_url = outputs[0]
parts = urlsplit(media_url)
if parts.scheme != "https" or not parts.hostname or parts.username or parts.password:
raise SystemExit("Unexpected media URL; inspect outputs manually")
# Separate media request: NEVER send your Jevrouter key to a media host.
with urlopen(media_url, timeout=120) as response, open("video.mp4", "wb") as output:
shutil.copyfileobj(response, output)
print("Saved video.mp4")
else:
print("This model returned structured outputs; inspect the printed result.")
break
else:
raise SystemExit("Still pending. Keep the state file and run again to resume.")The same request with cURL
Use either this request or the Python example for your test: running both creates two independent tasks. Keep the displayed idempotency key for retries of this exact request. Use a different key only for an intentionally new generation.
Save data.id from the response. Query GET /v1/videos/REQUEST_ID with the same Jevrouter key at least three seconds apart. data.status is queued, processing, completed or failed; data.outputs contains the result. This is an asynchronous JSON API, not OpenAI SDK video compatibility or a streaming response.
curl --fail-with-body https://api.jevrouter.io/v1/videos \
+ -H "Authorization: Bearer $JEVROUTER_API_KEY" \
+ -H "Content-Type: application/json" \
+ -H "Idempotency-Key: my-first-wan22-video" \
+ -d @- <<'JSON'
{
"model": "wan/wan-2.2-t2v-480p-ultra-fast",
"input": {
"prompt": "A yellow sailboat on a quiet lake",
"duration": 5,
"size": "832*480"
},
"jev": {
"max_cost_usd": "0.05"
}
}
JSONInspect status and recover an uncertain submission
A 503 outcome_unknown or 409 request_already_exists response can include the original request ID. Query that task instead of creating a replacement. If no ID arrived, repeat the exact original body with its original idempotency key. A new key can create a second billed video.
401/403 means check key access and the model allowlist. 402 means check balance, budgets and maximum reservation. 400 means correct the parameters. A 429 requires backoff. Other unexpected errors need investigation, not blind resubmission. A failed task is terminal; decide explicitly whether to create a new generation.
curl --fail-with-body "https://api.jevrouter.io/v1/videos/REQUEST_ID" \
-H "Authorization: Bearer $JEVROUTER_API_KEY"Switch models or use Auto
Model selection belongs in the request body. The same video endpoint handles text-to-video, image-to-video and video processing, but their inputs are not interchangeable. Each model page lists required fields, allowed values and its complete JSON schema. Put model-specific fields in input; supply accessible URLs for source media.
To use Auto, set model to jev/auto and task to the job type, such as text-to-video. Enable Auto for the project and allow both Auto and candidate models on the key. Common top-level duration, resolution and aspect_ratio options are translated where supported. Unknown or incompatible parameters are rejected rather than discarded.
Auto considers only the same task type and compatible parameters. It compares live quotes for up to eight candidates shortlisted by catalog base price, then chooses within that shortlist and your cost limit. It is not a quality ranking or a guarantee of the cheapest model across every variant.
Confirm the bill and keep your result
Check GET /v1/requests/REQUEST_ID or your project request history for reservation and settlement. Generation can finish before its bill arrives. The confirmed charge is capped at the reservation; unused reserved funds are released. Failed generation releases its reservation without a generation charge.
The platform handles upstream completion notifications and recovery internally. Your application still retrieves the task result; customer webhooks and cancellation are not supported. Result links are retained for up to seven days and may expire sooner. Download promptly and store the video yourself if you need a lasting copy. Treat media links as private because anyone with a link may be able to access it.
Models mentioned in this guide
Try it in your project.
Choose a model, create a scoped API key and inspect the result in Requests.
Open console ↗