On this page

Chapter 2 of 9. Prerequisite: Chapter 1. You need an activated model and an ARK_API_KEY in 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:

  1. POST /contents/generations/tasks returns immediately with a task id shaped cgt-… (the create-task reference documents the endpoint as POST https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks).
  2. GET /contents/generations/tasks/{id} returns the task record; you call it repeatedly until status reads succeeded (the retrieve-task reference documents this endpoint and the state machine behind it).
  3. 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.

  1. 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.

  2. Submit the task

    The minimal payload is a model id plus a content array 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".

  3. 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.

  4. Download the MP4
    curl -sSL -o first-render.mp4 "<the content.video_url value>"
    open first-render.mp4
    

    The 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:

FieldValuesNotes
resolution480p 720p 1080p 4k4k is Seedance 2.0 only, 10-bit H.265
ratio16:9 4:3 1:1 3:4 9:16 21:9 adaptive
duration2.0: 4 to 15, or -1 for auto; 1.x: 2 to 12seconds; the 2.0 4s floor matters later in the course
seedintegerfixes the noise seed for repeatable variation
camera_fixedbooleanlocks the camera; rejected outright in reference-media mode (chapter 5)
watermarkbooleantrue burns an "AI generated" tag into the pixels; set false for production
generate_audioboolean2.0 and 1.5-pro only
return_last_framebooleanhands back the final frame; feed it as the next task's first_frame and stitch with FFmpeg for long-form
draftboolean1.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 without content should print a readable error rather than a KeyError traceback.
  • The output filename is the task id. When you are running eight renders in a batch later, renders/cgt-….mp4 is 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".

  • queued is 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.
  • running means a GPU has the job. Nothing to do but wait.
  • succeeded is the only state that carries content.video_url. Terminal.
  • failed is 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.
  • expired means 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 a task_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=succeeded returns 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 still queued. Once it is running you 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, queued and running included, and the payload matches the retrieve-task response body, so your handler still checks for succeeded or failed; 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.

// EXERCISE

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.

// CHECKPOINT — THE TASK LOOP
multiple choice · auto-checked

You POST a generation task and the HTTP response arrives in well under a second. What did you just receive?

exact answer · auto-checked

Which field path on the succeeded task record holds the MP4 download URL?

open · self-checked

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

Sources

  • Create a video generation task
    BytePlus (ModelArk API reference)
    The POST endpoint this chapter drives, with every request parameter the payload section documents
    docs.byteplus.com
  • Retrieve a video generation task
    BytePlus (ModelArk API reference)
    The GET endpoint the poll loop hits, plus the task-state list including the queued-only cancellation rule
    docs.byteplus.com
  • Overview
    BytePlus (ModelArk documentation)
    The base URL and the ap-southeast-1 region every request in this chapter resolves against
    docs.byteplus.com
  • Video generation tutorial
    BytePlus (ModelArk documentation)
    BytePlus's own request schemas and code samples, the official counterpart to this chapter's Python example
    docs.byteplus.com
  • Best practices for handling burst traffic
    BytePlus (ModelArk documentation)
    The 429 rate-limit behaviour the chapter cites for pushing past the request-rate limits
    docs.byteplus.com
Back to guide overview