Video API: quote, generate, poll and download
Use a BetterToken API Key and a resumable Python example to quote, generate asynchronously, poll and download MP4 videos across ten models.
Video generation is asynchronous. Check model capabilities and request a quote before creating a task. Save its ID, poll for completion, then download when the video is ready.
Connection details
| Setting | Value |
|---|---|
| Base URL | https://bettertoken.ai/v1 |
| Authentication | Authorization: Bearer YOUR_API_KEY |
| List available models | GET /v1/video-models |
| Quote before generation | POST /v1/videos/quote |
| Create a task | POST /v1/videos |
| Read a task | GET /v1/videos/{id} |
| Download the video | GET /v1/videos/{id}/content |
| Compatible create endpoint | POST /v1/video/generations |
List available models
export BETTERTOKEN_API_KEY="YOUR_API_KEY"
curl --fail-with-body "https://bettertoken.ai/v1/video-models" \ -H "Authorization: Bearer $BETTERTOKEN_API_KEY"Each catalogue entry is identified by id and operation together. Follow its fields, media_limits and constraints; do not use one fixed parameter set for every operation.
Starting settings by family
| Family | Settings used in the example |
|---|---|
| Seedance CN | 5 seconds, 720p, 16:9, audio off |
| Kling 3.0 | 5 seconds, 16:9, standard mode; no generic resolution field |
| MiniMax H3 | 5 seconds, 768p, 16:9; provider-default audio |
| Wan 3.0 / Prime | 5 seconds, 720p, 16:9, audio off |
| Wan 2.7 T2V / I2V | Text: 5 seconds, 720p, 16:9; image: 5 seconds, 720p, ratio follows the input |
These are starting combinations for the script, not each model’s complete limits. Changing families can require more than changing the model name. Find the ten exact IDs and operations in the model lineup.
Complete Python workflow
Use Python 3; no extra dependencies are needed. Set the environment variables, save the code below as video_example.py and run it. Quoting does not start generation. Typing GENERATE submits a real task and can incur charges.
export BETTERTOKEN_API_KEY="YOUR_API_KEY"export VIDEO_MODEL="wan-2.7-t2v"export VIDEO_STATE_FILE="video-state.json"python3 video_example.pyUse a new VIDEO_STATE_FILE for a new task. To retry the same task, keep the request, BetterToken API Key and state file unchanged. The script reuses the Idempotency-Key or saved task ID. Its polling deadline is a client setting, not a generation-time promise.
import hashlibimport jsonimport osimport shutilimport timeimport uuidfrom pathlib import Pathfrom urllib.error import HTTPErrorfrom urllib.request import Request, urlopen
base_url = "https://bettertoken.ai/v1"api_key = os.environ["BETTERTOKEN_API_KEY"]model = os.getenv("VIDEO_MODEL", "wan-2.7-t2v")state_path = Path(os.getenv("VIDEO_STATE_FILE", "video-state.json"))sample_image = "https://docs.bettertoken.ai/images/nano-banana-guide/sample-07.png"
body = { "model": "wan-2.7-t2v", "operation": "text_to_video", "prompt": "A blue ceramic mug on a white studio table. The camera slowly moves closer. Soft daylight.", "duration": { "mode": "fixed", "seconds": 5 }, "output": { "resolution": "720p", "aspect_ratio": "16:9", "format": "mp4" }}seedance_models = { "seedance-2.5-cn", "seedance-2.0-cn", "seedance-2.0-fast-cn", "seedance-2.0-mini-cn",}allowed_models = seedance_models | { "kling-3.0", "minimax-h3", "wan-3.0", "wan-3.0-prime", "wan-2.7-t2v", "wan-2.7-i2v",}if model not in allowed_models: raise ValueError("Choose a Model ID from the documented batch")body["model"] = modelif model in seedance_models or model in {"wan-3.0", "wan-3.0-prime"}: body["audio"] = {"mode": "off"}elif model == "kling-3.0": body["output"].pop("resolution") body["audio"] = {"mode": "off"} body["provider_options"] = {"kling": {"mode": "std"}}elif model == "minimax-h3": body["output"]["resolution"] = "768p"elif model == "wan-2.7-i2v": body["operation"] = "image_to_video" body["prompt"] = "The camera slowly moves around the blue mug. Preserve its shape and the background." body["output"].pop("aspect_ratio") body["media"] = [{ "id": "first-frame", "type": "image", "role": "first_frame", "source": os.getenv("VIDEO_IMAGE_URL", sample_image), }]
request_file = os.getenv("VIDEO_REQUEST_FILE")if request_file: body = json.loads(Path(request_file).read_text(encoding="utf-8"))
def api(method, path, payload=None, extra_headers=None): headers = {"Authorization": f"Bearer {api_key}"} headers.update(extra_headers or {}) data = None if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" request = Request(base_url + path, data=data, headers=headers, method=method) try: with urlopen(request, timeout=60) as response: return json.load(response) except HTTPError as error: message = error.read().decode("utf-8", errors="replace") raise RuntimeError(f"HTTP {error.code}: {message}") from error
digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode("utf-8")).hexdigest()if state_path.exists(): state = json.loads(state_path.read_text(encoding="utf-8")) if state["request_sha256"] != digest: raise ValueError("Request changed. Use a new VIDEO_STATE_FILE for a new task")else: state = {"request_sha256": digest, "idempotency_key": str(uuid.uuid4())} state_path.write_text(json.dumps(state), encoding="utf-8")
task_id = state.get("task_id")if not task_id: if not state.get("create_attempted"): catalogue = api("GET", "/video-models") if not any(row["id"] == body["model"] and row["operation"] == body["operation"] for row in catalogue["data"]): raise RuntimeError("This model and operation are not in the current catalogue") quote = api("POST", "/videos/quote", body) print(json.dumps(quote, ensure_ascii=False, indent=2)) if input("Review the quote. Type GENERATE to create a paid task: ") != "GENERATE": raise SystemExit("No generation request sent") if time.time() >= quote["expires_at"]: raise RuntimeError("Quote expired. Re-run to request a new quote") state["create_attempted"] = True state_path.write_text(json.dumps(state), encoding="utf-8") task = api("POST", "/videos", body, {"Idempotency-Key": state["idempotency_key"]}) task_id = task["id"] state["task_id"] = task_id state_path.write_text(json.dumps(state), encoding="utf-8")
print("Task ID:", task_id)deadline = time.monotonic() + 1800while time.monotonic() < deadline: task = api("GET", f"/videos/{task_id}") print(task["status"], task["stage"], task["delivery_status"], task["financial_status"]) if task["status"] in {"failed", "canceled"}: raise RuntimeError(json.dumps(task.get("error"), ensure_ascii=False)) if task["delivery_status"] in {"expired", "unavailable"}: raise RuntimeError("Video download is no longer available") if task["status"] == "completed" and task["delivery_status"] == "ready": print(json.dumps(task["billing"], ensure_ascii=False, indent=2)) break time.sleep(5)else: raise TimeoutError("Task still pending. Re-run with the same state file to resume")
request = Request(base_url + f"/videos/{task_id}/content", headers={"Authorization": f"Bearer {api_key}"})with urlopen(request, timeout=120) as response: if response.headers.get_content_type() != "video/mp4": raise RuntimeError("Expected an MP4 response; inspect the task before retrying") with Path("video.mp4").open("wb") as output: shutil.copyfileobj(response, output)print("Saved video.mp4")Image-to-video
Set VIDEO_MODEL to wan-2.7-i2v to use the public sample image below. Set VIDEO_IMAGE_URL to use your own authorized HTTPS image that the server can access. Do not pass output.aspect_ratio for this entry.

{ "model": "wan-2.7-i2v", "operation": "image_to_video", "prompt": "The camera slowly moves around the blue mug. Preserve the mug shape and the white background.", "duration": { "mode": "fixed", "seconds": 5 }, "output": { "resolution": "720p", "format": "mp4" }, "media": [ { "id": "first-frame", "type": "image", "role": "first_frame", "source": "https://docs.bettertoken.ai/images/nano-banana-guide/sample-07.png" } ]}Reference generation and custom requests
Save the JSON below as request.json, set VIDEO_REQUEST_FILE=request.json and use a new state file for the new task. The script quotes, confirms, creates, polls and downloads the same request. Media must meet the selected model’s type and size rules.
{ "model": "seedance-2.5-cn", "operation": "reference_to_video", "prompt": "Create a studio product video of the blue mug in the reference image. Keep the mug design consistent.", "duration": { "mode": "fixed", "seconds": 5 }, "output": { "resolution": "720p", "aspect_ratio": "16:9", "format": "mp4" }, "audio": { "mode": "off" }, "media": [ { "id": "reference-1", "type": "image", "role": "reference_image", "source": "https://docs.bettertoken.ai/images/nano-banana-guide/sample-07.png" } ]}Regenerate an H3 result
Replace the placeholder with your own completed, still-downloadable 768p H3 task ID that meets regeneration requirements. This creates a new paid task using the custom-request workflow above. Do not add a new prompt, duration, output settings or media; the server reuses the original task and validates eligibility.
{ "model": "minimax-h3", "operation": "video_edit", "provider_options": { "hailuo": { "source_task_id": "YOUR_COMPLETED_H3_TASK_ID" } }}Recognize task completion
| Field or status | Action |
|---|---|
202 | Accepted only; the video is not finished yet |
queued / in_progress | Still queued or running; keep polling |
completed + delivery_status: ready | Ready to download |
failed / canceled | Stop polling and read the error; do not automatically create a new task |
delivery_status: expired / unavailable | The video cannot be downloaded from this task |
financial_status | Billing is separate from execution and download readiness; wait for confirmation |
Read quotes and settlement
currency identifies the quoted currency; do not put it in the generation JSON. reserved_amount is the reservation and max_charge_amount is the quote ceiling. Creation recalculates and freezes the task quote. Read billing.currency, billing.net_amount and financial_status for settlement. Amounts are decimal strings; missing or null values do not mean free. Check current pricing in the Current models and pricing.
Common errors
| Symptom | Next step |
|---|---|
401 | Check the API Key; never put it in JSON or public logs |
409 | Keep the original idempotency key and request; query a known task ID first |
422 | Correct the field using the error’s field path and allowed values |
410 | The video expired; download future results before expiry |
429 / 503 / timeout | Query an existing task first; after uncertain creation, reuse the original idempotency key rather than automatically sending a new one |

