What an AI video generation API actually is
It's not a no-code button. It's an asynchronous job: you POST a prompt, the API hands back an ID, the model renders for anywhere from thirty seconds to several minutes, and you poll a second endpoint until the video is ready. That render delay is the whole reason for the two-step design — video takes too long to hold an HTTP connection open, so you get a job ticket instead of a file.
Three request shapes cover almost everything you'll build:
- text-to-video — a prompt becomes a clip.
- image-to-video — a still frame is animated forward.
- reference-to-video — one or more reference images keep a character or object consistent across shots.
Everything below uses the same base URL, https://gptproto.com/api/v3, and the same header. Let's get a key and make the first call.
Get your API key
Sign up at gptproto.com, open the API Keys section of the dashboard, and create a key. It looks like sk-.... There's no standing free tier here — billing is prepaid pay-as-you-go, so you top up a balance and every call draws from it starting with the first request. Budget a couple of dollars for testing; a handful of short clips won't cost more than that.
Install the one dependency:
pip install requests
One thing to get right before your first call, because it trips people up: on the native /api/v3/ surface, the Authorization header is your raw key with no Bearer prefix. GPT Proto also exposes an OpenAI-compatible /v1/ surface, and that one uses Bearer. Mixing them up is the most common 401 I see. For this whole tutorial we stay on the native surface, so the key goes in raw.
Your first call: Seedance 2.0, text-to-video
Seedance 2.0 is ByteDance's second-generation video model. It renders 4-to-15-second clips up to 1080p with native, synchronized audio, and it's built for multi-shot scenes where the camera cuts and the subject stays consistent. Here's a complete text-to-video submission:
import requests
import time
API_KEY = "sk-your-key-here" # raw, no "Bearer" prefix
BASE = "https://gptproto.com/api/v3"
headers = {
"Authorization": API_KEY,
"Content-Type": "application/json",
}
def submit_seedance(prompt):
url = f"{BASE}/bytedance/dreamina-seedance-2-0-260128/text-to-video"
payload = {
"prompt": prompt,
"duration": 5, # 4-15 seconds
"aspect_ratio": "16:9",
"resolution": "1080p",
"generate_audio": True,
"camera_fixed": False,
"seed": -1, # -1 = random
}
resp = requests.post(url, headers=headers, json=payload)
resp.raise_for_status()
return resp.json()["data"]["id"]
prompt = (
"A lighthouse keeper climbs a narrow spiral staircase at dawn. "
"Cut to a wide shot of the lamp room as the light sweeps across "
"grey water. Waves crash below. Ambient wind and distant gulls."
)
job_id = submit_seedance(prompt)
print("submitted:", job_id)
The same request as cURL, if you'd rather see the wire format:
curl -X POST "https://gptproto.com/api/v3/bytedance/dreamina-seedance-2-0-260128/text-to-video" \
-H "Authorization: sk-your-key-here" \
-H "Content-Type: application/json" \
-d '{
"prompt": "A lighthouse keeper climbs a spiral staircase at dawn, then the lamp sweeps across grey water.",
"duration": 5,
"aspect_ratio": "16:9",
"resolution": "1080p",
"generate_audio": true,
"camera_fixed": false,
"seed": -1
}'
The response doesn't contain a video. It contains a job:
{
"data": {
"id": "cgt-20260709xxxxxx-abc",
"status": "created",
"outputs": [],
"urls": { "get": "https://gptproto.com/api/v3/predictions/cgt-20260709xxxxxx-abc/result" }
},
"message": "success",
"code": 200
}
data.id is your ticket. data.urls.get is a ready-made poll URL with the ID already baked in. In a nutshell: you submitted work, you got an ID, and nothing is rendered yet.
Poll for the result
Now you hit the result endpoint until status flips to completed, then read the video URL out of outputs. Every model on the platform uses this same poll endpoint and the same response shape, which is what makes the "swap one string" trick later work:
def wait_for_video(job_id, every=5, timeout=600):
url = f"{BASE}/predictions/{job_id}/result"
waited = 0
while waited < timeout:
resp = requests.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()["data"]
status = data["status"]
if status == "completed":
return data["outputs"][0] # the MP4 URL
if data.get("error"):
raise RuntimeError(data["error"])
print(f" {status}... {waited}s")
time.sleep(every)
waited += every
raise TimeoutError(f"timed out after {timeout}s (last status: {status})")
video_url = wait_for_video(job_id)
print("done:", video_url)
That's a full working generation. Two functions, one prompt, one MP4. A 5-second clip usually finishes in a minute or two, though a 15-second 1080p render with audio can take longer — poll every few seconds and don't hammer it.
A note on the loop: I raise on error and treat any non-completed, non-error status as "keep waiting." That's deliberate. Different models report intermediate states differently, and the safe move is to wait through anything that isn't a hard failure rather than hard-code a list of statuses that might change.
Swap one string, swap the engine: ViduQ3-pro
Here's the payoff. To render the same prompt on ViduQ3-pro instead of Seedance, you change the path — nothing else. The key, the headers, and wait_for_video() all stay exactly as they are:
def submit_vidu(prompt):
url = f"{BASE}/vidu/viduq3-pro/text-to-video"
payload = {
"prompt": prompt,
"duration": 5, # ViduQ3 goes up to 16s
"aspect_ratio": "16:9",
"resolution": "1080p",
"audio": True, # note: ViduQ3 uses `audio`, not `generate_audio`
"seed": -1,
}
resp = requests.post(url, headers=headers, json=payload)
resp.raise_for_status()
return resp.json()["data"]["id"]
job_id = submit_vidu(prompt)
video_url = wait_for_video(job_id) # same poller, unchanged
ViduQ3-pro pushes clips to 16 seconds — twice Seedance's practical range — and it's the cheapest of the models here per second. The parameter names shift a little from model to model (ViduQ3 controls sound with audio; Seedance uses generate_audio), so check the model page for the exact field set, but the request/submit/poll rhythm never changes.
Keeping a character consistent. Both hero models do more than text-to-video. Point ViduQ3 at /vidu/viduq3-pro/image-to-video with an image field to animate a still, or use Seedance's reference-to-video mode with reference images so the same face carries across separate shots. It's the same submit-and-poll flow — you're adding an image input, not learning a new API. Confirm the reference-field names on each model page before you wire it into production.
Which one should you call?
Five models, one key. They aren't ranked tiers where a bigger number always wins — they're different jobs. Pick by what the clip needs:
| Model |
Path ({provider}/{model}) |
Native audio |
Max length |
Price |
Reach for it when |
| Seedance 2.0 |
bytedance/dreamina-seedance-2-0-260128 |
Yes |
~15s |
from $0.2957/run |
Multi-shot scenes with synced sound |
| ViduQ3-pro |
vidu/viduq3-pro |
Yes |
16s |
from $0.04/s |
Longer clips, tightest price |
| Kling v3.0 std |
kling/kling-v3.0-std |
Yes |
~10s |
from $0.2016/run |
Cheap, dependable image-to-video |
| Hailuo 2.3 Pro |
minimax/hailuo-2.3-pro |
No |
~10s |
$0.441/run |
Clean 1080p when you'll add sound yourself |
| Veo 3.1 |
google/veo3.1 |
Yes |
— |
from $0.5/run |
You need 4K output |
Two things worth flagging before you commit. Hailuo 2.3 Pro returns silent video — great picture, but you're scoring it in post. And Veo 3.1 stamps every output with an embedded SynthID watermark you can't turn off, which matters if you're delivering to a client who doesn't want provenance marks baked in.
Because the platform normalizes all of these behind one submit/poll contract, iterating across them is a dictionary lookup, not a rewrite:
MODELS = {
"seedance": "bytedance/dreamina-seedance-2-0-260128",
"vidu": "vidu/viduq3-pro",
"kling": "kling/kling-v3.0-std",
"hailuo": "minimax/hailuo-2.3-pro",
"veo": "google/veo3.1",
}
def submit(model_key, scene, payload):
url = f"{BASE}/{MODELS[model_key]}/{scene}"
resp = requests.post(url, headers=headers, json=payload)
resp.raise_for_status()
return resp.json()["data"]["id"]
# same wait_for_video() retrieves any of them
job_id = submit("kling", "image-to-video", {"prompt": prompt, "image": image_url})
Payloads differ per model — that's the part you tune — but discovery, auth, and retrieval don't.
What it actually costs
Video pricing is per second or per run, and it scales hard with resolution, duration, and whether you generate audio. The headline number on a model page is a floor, not a forecast. As a real example, a Seedance 720p / 16:9 / 5-second clip with audio comes out around $0.605 — well above its $0.2957 baseline — because you moved every cost lever at once. Price your actual settings from the dashboard estimate, not the sticker.
Here's the honest comparison against calling each vendor directly:
- ViduQ3-pro — from $0.04/s, roughly 20% under the ~$0.05/s market reference.
- Kling v3.0 std — from $0.2016/run, about 20% under the ~$0.252 reference.
- Hailuo 2.3 Pro — $0.441/run against MiniMax's ~$0.49, about 10% cheaper.
- Seedance 2.0 — from $0.2957/run, which is about 10% above rendering the same clip directly on ByteDance's own Dreamina. I'm not going to pretend otherwise. The reason to call it through the API isn't a lower price — it's stable programmatic access, one balance, and no BytePlus identity check. If cost is the only thing you care about for Seedance specifically, going direct is cheaper.
- Veo 3.1 — from $0.5/run; there's no clean market reference on the page, so treat it as a convenience play, not a discount.
There's no free tier and no monthly subscription — you pay per call from the first request. For pricing on any specific model, the model catalog shows the current per-second and per-resolution tables.
Errors and gotchas
A short field guide to what breaks:
- 401 Unauthorized — almost always the
Bearer mix-up. The native /api/v3/ surface wants the raw key.
- 403 Forbidden — usually an empty balance, not a permissions problem. Top up.
- 429 — you're rate-limited; back off and retry.
- 400 with a content message — the prompt tripped moderation. Responses also carry a
has_nsfw_contents flag worth checking in a pipeline.
- Poll never completes — long renders exist; that's why
wait_for_video() has a timeout instead of looping forever.
- Wrong audio parameter —
generate_audio (Seedance) vs audio (ViduQ3). Sending the wrong one silently does nothing rather than erroring, which is worse. Check the field names per model.
Start here
The fastest path: grab a key, run the Seedance and ViduQ3 snippets above, then swap the model string to try the rest. Everything you need to pick a model — current pricing, supported modes, resolution tables — is on each model page in the catalog. One key, one balance, five video engines.