Chapter 2 of 9. Prerequisite: Chapter 1. You need an activated model and an
ARK_API_KEYin your environment, and nothing else. This chapter drives the generation API raw: one curl walkthrough, then a forty-line Python script that submits, polls and downloads. On the 1.0-pro free quota the smoke test costs $0.
The task pattern: submit returns an id, never a video
The single most common way people mishandle this API is expecting the video in the HTTP response. It is never there. A render takes one to three minutes of GPU time; no sane HTTP server holds a connection open for that. So the API is asynchronous, and the whole surface is three moves:
POST /contents/generations/tasksreturns immediately with a task id shapedcgt-…(the create-task reference documents the endpoint asPOST https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks).GET /contents/generations/tasks/{id}returns the task record; you call it repeatedly untilstatusreadssucceeded(the retrieve-task reference documents this endpoint and the state machine behind it).- The succeeded record carries
content.video_url, a signed MP4 URL. You download it.
Both endpoints live under one base URL, https://ark.ap-southeast.bytepluses.com/api/v3, in the ap-southeast-1 region. That base URL is stated verbatim in the ModelArk overview, which also notes that all listed models are served from ap-southeast-1. There is no region negotiation to do; you hardcode this one string.
Submit, poll, download. Every chapter after this one, including the eight-beat parallel render batches later in the course, is that loop with better inputs.
Submit with curl: four steps to a first render
Setup chapter rules apply: run every step, in order, and read every response. The model here is seedance-1-0-pro-250528 because it carries 2,000,000 free tokens, so a wrong payload costs you nothing while you learn the contract.
- Guard the key
The API authenticates with a bearer header,
Authorization: Bearer $ARK_API_KEY, per the official API key guide, which walks through generating the key in the console and configuring it "securely as an environment variable". Chapter 1 put it in your environment. Start every script with a guard so a missing key dies loudly instead of producing a confusing 401:: "${ARK_API_KEY:?ARK_API_KEY is not set. Run: export ARK_API_KEY='...'}" BASE="https://ark.ap-southeast.bytepluses.com/api/v3"The
:?form aborts the shell with that exact message when the variable is unset. The key never appears in the script, in git, or in your history. - Submit the task
The minimal payload is a model id plus a
contentarray holding one text item:MODEL="seedance-1-0-pro-250528" PROMPT="A red kite drifting over a wet cobbled street at dawn, slow push-in" curl -sS "$BASE/contents/generations/tasks" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $ARK_API_KEY" \ -d "{\"model\":\"$MODEL\",\"content\":[{\"type\":\"text\",\"text\":\"$PROMPT\"}]}"The response comes back in under a second:
{"id":"cgt-<task-id>"}That id is the only thing you get. Save it. The model id itself comes from the official model list; the 1.0-pro card on seed.bytedance.com describes what you just asked it for, 1080p-capable video "with smooth motion, rich details, and cinematic aesthetics".
- Poll the id
Ask for the task record:
curl -sS "$BASE/contents/generations/tasks/cgt-<task-id>" \ -H "Authorization: Bearer $ARK_API_KEY"Early on you get:
{"id":"cgt-<task-id>","status":"queued"}then
"status":"running", and after one to three minutes:{ "id": "cgt-<task-id>", "status": "succeeded", "content": { "video_url": "https://.../<signed-path>.mp4" } }Re-run the curl every ten seconds or so by hand. The Python script below automates exactly this cadence.
- Download the MP4
curl -sSL -o first-render.mp4 "<the content.video_url value>" open first-render.mp4The first time this pipeline ran end to end here, the very first smoke test, the task went submitted, then running, then succeeded, and the 5s 720p MP4 played back clean and matched the prompt. That one render proved key, activation, submit, poll and download were all live before any money was spent on production shots. Do the same: prove the pipe on a free render before you touch Seedance 2.0.
The full request body: every field the endpoint takes
The minimal payload renders with defaults. Production payloads use the full body, documented parameter by parameter in the create-task reference. The shape, with the fields you will actually set:
{
"model": "seedance-1-0-pro-250528",
"content": [
{ "type": "text", "text": "prompt here" },
{ "type": "image_url", "image_url": { "url": "https://... or data:image/png;base64,..." },
"role": "first_frame" },
{ "type": "image_url", "image_url": { "url": "..." }, "role": "last_frame" }
],
"resolution": "720p",
"ratio": "16:9",
"duration": 5,
"seed": 11,
"camera_fixed": false,
"watermark": true,
"generate_audio": true,
"return_last_frame": true,
"draft": true,
"service_tier": "flex"
}
Field by field:
| Field | Values | Notes |
|---|---|---|
resolution | 480p 720p 1080p 4k | 4k is Seedance 2.0 only, 10-bit H.265 |
ratio | 16:9 4:3 1:1 3:4 9:16 21:9 adaptive | |
duration | 2.0: 4 to 15, or -1 for auto; 1.x: 2 to 12 | seconds; the 2.0 4s floor matters later in the course |
seed | integer | fixes the noise seed for repeatable variation |
camera_fixed | boolean | locks the camera; rejected outright in reference-media mode (chapter 5) |
watermark | boolean | true burns an "AI generated" tag into the pixels; set false for production |
generate_audio | boolean | 2.0 and 1.5-pro only |
return_last_frame | boolean | hands back the final frame; feed it as the next task's first_frame and stitch with FFmpeg for long-form |
draft | boolean | 1.5-pro only, 480p only; cheap preview mode |
service_tier | "flex" | offline queue at 50% cost; not available on 2.0 |
The content array is where the whole course lives. Text plus image_url items with role: "first_frame" or role: "last_frame" is classic image-to-video; the multimodal reference roles arrive in chapter 3 onward. BytePlus's video generation tutorial states the frame: Seedance models "can quickly generate high-quality video clips based on multimodal content such as text, images, videos, and audio input by users".
A Seedance API Python example: submit, poll, download
Here is the whole loop as one stdlib-only script. No SDK, no dependencies, and it mirrors what the curl steps did by hand: submit, poll every 10 seconds, download to renders/<task_id>.mp4. The request schemas match the official video generation tutorial; the endpoints match the create and retrieve references.
#!/usr/bin/env python3
"""Submit a Seedance generation task, poll it, download the MP4."""
import json
import os
import sys
import time
import urllib.request
BASE = "https://ark.ap-southeast.bytepluses.com/api/v3"
API_KEY = os.environ.get("ARK_API_KEY")
if not API_KEY:
sys.exit("ARK_API_KEY is not set. Run: export ARK_API_KEY='your-key'")
def api(method, path, body=None):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode() if body is not None else None,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}",
},
method=method,
)
with urllib.request.urlopen(req) as resp:
return json.load(resp)
# 1. Submit
task = api("POST", "/contents/generations/tasks", {
"model": "seedance-1-0-pro-250528",
"content": [
{"type": "text",
"text": "A red kite drifting over a wet cobbled street at dawn, slow push-in"},
],
"resolution": "720p",
"ratio": "16:9",
"duration": 5,
})
task_id = task["id"]
print(f"submitted: {task_id}")
# 2. Poll every 10s until a terminal state (usually 1-3 min total)
while True:
got = api("GET", f"/contents/generations/tasks/{task_id}")
status = got.get("status", "")
print(f" {status}")
if status == "succeeded":
break
if status in ("failed", "expired"):
sys.exit(f"task {task_id} ended {status}: {got.get('error')}")
time.sleep(10)
# 3. Download the signed MP4
url = (got.get("content") or {}).get("video_url", "")
if not url:
sys.exit("succeeded but no content.video_url on the task record")
os.makedirs("renders", exist_ok=True)
dest = os.path.join("renders", f"{task_id}.mp4")
urllib.request.urlretrieve(url, dest)
print(f"saved: {dest}")
Details that are deliberate, and worth keeping when you adapt it:
- The key guard dies with
ARK_API_KEY is not set. Run: export ARK_API_KEY='your-key'before any network call. Silent auth failures waste polling time; loud ones cost a glance. - The URL extraction is
(got.get("content") or {}).get("video_url", ""), defensive on both levels. A succeeded record withoutcontentshould print a readable error rather than aKeyErrortraceback. - The output filename is the task id. When you are running eight renders in a batch later,
renders/cgt-….mp4is the only naming scheme that never collides and always traces back to a task record. - Ten seconds is the right poll interval. Renders take one to three minutes; polling every second buys you nothing and eats your request-rate budget.
Run it, watch queued then running scroll by, and you have the primitive the rest of the course composes.
The five task states: what each one means for your loop
The task record's status field takes exactly five values in normal operation: queued, running, succeeded, failed, expired. Your loop must handle all five, plus one more the retrieve-task reference documents: cancelled, with the constraint quoted there as "Only tasks in the queued state can be canceled".
queuedis not an error and not a stall. Individual accounts run 3 concurrent non-4K generations (1 concurrent at 4K, with request rates of 180 RPM non-4K and 15 RPM at 4K). Submit a fourth task while three run and it queues; it starts when a slot frees. This is the property the parallel pattern in chapter 3 exploits: you submit an entire batch at once and let the queue drain it. Push past the request-rate limits themselves and the API answers with a 429, per the official burst-traffic best practices.runningmeans a GPU has the job. Nothing to do but wait.succeededis the only state that carriescontent.video_url. Terminal.failedis terminal and carries an error object. Print it whole; the 400-class messages later in this course (moderation rejections, pixel-floor violations, mutually exclusive input modes) are precise enough to act on verbatim.expiredmeans the task record aged out. Terminal. If you ever see it, your poller stopped and something else did not notice, which is exactly the silent failure mode atask_id-named MP4 on disk lets you audit for.
The state machine only moves forward. There is no retry-in-place; a failed task is resubmitted as a new task with a new id.
List, cancel, webhook: the rest of the task surface
Three more calls round out the surface, all under the same base URL:
- List:
GET /contents/generations/tasks?filter.status=succeededreturns your task records filtered by state. Useful for reconciling a batch after the fact: every succeeded task you have no local MP4 for is a download you dropped. - Cancel/delete:
DELETE /contents/generations/tasks/{id}. Remember the constraint from the retrieve-task reference: cancellation only applies while the task is stillqueued. Once it isrunningyou are paying for it either way. - Webhook: the create-task request body accepts a
callback_url. ModelArk POSTs the task record to it on every status change,queuedandrunningincluded, and the payload matches the retrieve-task response body, so your handler still checks forsucceededorfailed; what it removes is the poll loop itself. For a laptop pipeline, polling is simpler and has no inbound-URL requirement. The webhook earns its keep when the pipeline runs on a server that is already publicly reachable.
This course polls. When chapter 3 hosts reference media behind a tunnel for the parallel batches, the tunnel exists for the model to fetch inputs, and the poll loop stays exactly the ten-second loop you just wrote.
What the smoke test cost: receipts
Real numbers from this pipeline's own ledger, all on the models named above:
- The first end-to-end smoke test (5s, 720p, 1.0-pro): $0, inside the 2,000,000-token free quota.
- Measured burn: a 5s 720p clip on 1.0-pro costs about 110K tokens. The docs' reference point is roughly 247K tokens for 5s at 1080p, and 720p lands near 0.44 of that. The free quota therefore holds about 18 free 720p clips, around 8 at 1080p, around 40 at 480p. Off the free quota, 1.0-pro's rate works out near $0.28 per 720p clip-equivalent. The billing formulas per model are on the official pricing page.
- The first Seedance 2.0 call made without a purchased resource pack failed with
SetLimitExceeded. That is a billing block, not a code bug: 2.0 has zero free quota, and the account's safety mode paused the task at zero. The failed task incurred no charge. If you skipped the resource-pack step in chapter 1, this is the error that tells you.
The discipline to take from this chapter is cheap and permanent: prove the loop on a free 1.0-pro render, keep the task id in the filename, and read failed records instead of resubmitting blind. Chapter 3 wraps this loop in a CLI, adds reference media, and then breaks the one-task-at-a-time habit for good: a whole beat list submitted in parallel, three slots draining the queue, one poll loop watching all of it.
Wrap the loop in your own submit tool
Extend the Python example into a small CLI you will keep for the rest of the course: it takes a prompt and optional model, resolution and duration flags, submits the task, polls with visible status lines, downloads to renders/<task_id>.mp4, and exits non-zero on failed or expired with the API's error object printed whole.
Expected behaviour
- Running it with ARK_API_KEY unset aborts before any network call with a message naming the variable
- A successful run prints the cgt- task id at submit time and at least one queued or running status line before succeeded
- The downloaded file lands at renders/<task_id>.mp4 and plays
- A run against a deliberately invalid body, such as duration 99, exits non-zero and prints the API's own error text rather than a traceback
- Submitting four tasks in quick succession shows at least one of them sitting in queued while others run
PROVE IT Run the tool twice on camera: once end to end showing submit, poll lines and the saved renders/<task_id>.mp4, and once with ARK_API_KEY unset showing the guard firing before any request is made.
You POST a generation task and the HTTP response arrives in well under a second. What did you just receive?
Which field path on the succeeded task record holds the MP4 download URL?
You submit ten tasks in one loop. Three go to running and seven sit in queued for several minutes. Why is this not a failure, and what account limit explains the number three?
Show answer
Individual accounts run 3 concurrent non-4K generations, so only three tasks can hold a GPU slot at once. The other seven queue rather than fail, and each starts automatically when a slot frees. queued is a normal state the poll loop simply waits through, and it is the property the parallel batch pattern in chapter 3 is built on.
↺ re-read: “The five task states: what each one means for your loop”