Skip to content
Sign Up Free
API Reference

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

SettingValue
Base URLhttps://bettertoken.ai/v1
AuthenticationAuthorization: Bearer YOUR_API_KEY
List available modelsGET /v1/video-models
Quote before generationPOST /v1/videos/quote
Create a taskPOST /v1/videos
Read a taskGET /v1/videos/{id}
Download the videoGET /v1/videos/{id}/content
Compatible create endpointPOST /v1/video/generations

List available models

Terminal window
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

FamilySettings used in the example
Seedance CN5 seconds, 720p, 16:9, audio off
Kling 3.05 seconds, 16:9, standard mode; no generic resolution field
MiniMax H35 seconds, 768p, 16:9; provider-default audio
Wan 3.0 / Prime5 seconds, 720p, 16:9, audio off
Wan 2.7 T2V / I2VText: 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.

Terminal window
export BETTERTOKEN_API_KEY="YOUR_API_KEY"
export VIDEO_MODEL="wan-2.7-t2v"
export VIDEO_STATE_FILE="video-state.json"
python3 video_example.py

Use 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 hashlib
import json
import os
import shutil
import time
import uuid
from pathlib import Path
from urllib.error import HTTPError
from 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"] = model
if 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() + 1800
while 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.

Image-to-video input: a blue ceramic mug on a white background
{
"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 statusAction
202Accepted only; the video is not finished yet
queued / in_progressStill queued or running; keep polling
completed + delivery_status: readyReady to download
failed / canceledStop polling and read the error; do not automatically create a new task
delivery_status: expired / unavailableThe video cannot be downloaded from this task
financial_statusBilling 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

SymptomNext step
401Check the API Key; never put it in JSON or public logs
409Keep the original idempotency key and request; query a known task ID first
422Correct the field using the error’s field path and allowed values
410The video expired; download future results before expiry
429 / 503 / timeoutQuery an existing task first; after uncertain creation, reuse the original idempotency key rather than automatically sending a new one