Developer API
Guide · Video · Responses · Messages · Billing & Ink
Video generation is asynchronous. You submit a request and receive a generation id at once; the clip renders in the background; you poll until it is succeeded and then read a signed download link.
| Step | Call |
|---|---|
| Submit | POST /v1/video/generations → 202 with id and status: "queued" |
| Poll | GET /v1/video/generations/{id} until status is succeeded or failed |
| Download | video.url from the poll response, or GET /v1/video/generations/{id}/content for a fresh redirect |
Models: seedance-2.5 (4–30 s, up to 1080p), seedance-2.0 (4–15 s, up to 1080p) and seedance-2.0-fast (4–15 s, up to 720p). Rates are on Billing & Ink; a 5-second 1080p Seedance 2.5 clip is 284 Ink.
Ink is reserved when the request is accepted, for the requested duration at the requested resolution, and settled to the duration the vendor reports (never more than requested). A failed generation refunds the whole reservation. At most 2 generations run at once per account; results are kept for 7 days.
POST /v1/video/generations with a JSON body of up to 64 KiB. The response is 202 Accepted:
curl
curl https://loopling.ai/v1/video/generations \
-H "Authorization: Bearer $LOOPLING_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance-2.5",
"prompt": "A paper lantern drifting over a night harbour, slow dolly in",
"aspect_ratio": "16:9",
"resolution": "1080p",
"duration": 5
}'Node
const res = await fetch('https://loopling.ai/v1/video/generations', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LOOPLING_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'seedance-2.5',
prompt: 'A paper lantern drifting over a night harbour, slow dolly in',
aspect_ratio: '16:9',
resolution: '1080p',
duration: 5,
}),
})
const generation = await res.json() // { id: 'vgen_…', status: 'queued', ink_reserved, … }Python
import os, requests
res = requests.post(
"https://loopling.ai/v1/video/generations",
headers={"Authorization": f"Bearer {os.environ['LOOPLING_API_KEY']}"},
json={
"model": "seedance-2.5",
"prompt": "A paper lantern drifting over a night harbour, slow dolly in",
"aspect_ratio": "16:9",
"resolution": "1080p",
"duration": 5,
},
)
generation = res.json() # {"id": "vgen_…", "status": "queued", "ink_reserved": …}{
"id": "vgen_01J9X6K3M8Q2",
"object": "video.generation",
"status": "queued",
"model": "seedance-2.5",
"created_at": "2026-09-17T08:12:44Z",
"ink_reserved": 284,
"usd_payg_reserved": 2.84,
"request": {
"model": "seedance-2.5",
"prompt": "A paper lantern drifting over a night harbour, slow dolly in",
"aspect_ratio": "16:9",
"resolution": "1080p",
"duration": 5,
"generate_audio": true
}
}
request is the normalised request as accepted — defaults filled in, reference lists in the order they were given, metadata echoed inside it. ink_reserved is the hold; usd_payg_reserved is the same figure at $1 = 100 Ink. Rendering typically takes one to a few minutes depending on duration and resolution.
| Field | Type | Required | Notes | ||||||
|---|---|---|---|---|---|---|---|---|---|
model | "seedance-2.5" \ | "seedance-2.0" \ | "seedance-2.0-fast" | yes | unknown_model otherwise. | ||||
prompt | string | no | Up to 4,000 characters. Required unless a first frame or an image / video reference carries the intent (missing_prompt). | ||||||
aspect_ratio | "21:9" \ | "16:9" \ | "4:3" \ | "1:1" \ | "3:4" \ | "9:16" \ | "adaptive" | no | Default "16:9". "adaptive" follows the first frame or reference. |
resolution | "480p" \ | "720p" \ | "1080p" | no | Default "720p". seedance-2.0-fast has no 1080p; seedance-2.5 refuses 1080p with "9:16" or "adaptive" — both answer unsupported_resolution. | ||||
duration | integer seconds | no | Default 5. 4–30 for seedance-2.5; 4–15 for seedance-2.0 and seedance-2.0-fast (invalid_duration). There is no automatic duration in v1: "auto" or -1 answers unsupported_parameter. | ||||||
generate_audio | boolean | no | Default true. | ||||||
seed | integer | no | -1 to 2147483647; a reproducibility hint the vendor may ignore. | ||||||
first_frame_url | https URL | no | Start frame. Image; publicly fetchable. | ||||||
last_frame_url | https URL | no | End frame; requires first_frame_url (missing_first_frame). | ||||||
reference_image_urls | https URL[] | no | Style / subject references. | ||||||
reference_video_urls | https URL[] | no | Motion references. | ||||||
reference_audio_urls | https URL[] | no | Audio references. On seedance-2.0 / -fast an audio reference needs an image or video beside it; seedance-2.5 accepts audio alone. | ||||||
metadata | object | no | Up to 4 KiB of JSON, echoed back inside request. |
Reference limits, per generation: seedance-2.5 takes up to 50 references — 30 images, 10 videos, 10 audio; seedance-2.0 and seedance-2.0-fast take up to 12 — 9 images, 3 videos, 3 audio. A list longer than its per-kind cap answers too_many; a set that breaks the model's total or its audio-anchor rule answers reference_limit. Frames (first_frame_url / last_frame_url) and reference_* inputs are mutually exclusive on Seedance — combining them answers conflicting_references.
Every URL must be https:// without embedded credentials, at most 2,048 characters, and publicly fetchable at submit time (invalid_url); Loopling reads it once, on your behalf, and does not keep the source.
An optional Idempotency-Key header makes the submit safe to retry — see Idempotency.
GET /v1/video/generations/{id} returns the generation's current state. Poll every few seconds; the per-key rate limit applies to polling like any other call, so five-second intervals are a sensible floor.
curl
curl https://loopling.ai/v1/video/generations/vgen_01J9X6K3M8Q2 \
-H "Authorization: Bearer $LOOPLING_API_KEY"Node
const headers = { Authorization: `Bearer ${process.env.LOOPLING_API_KEY}` }
async function waitForVideo(id) {
for (;;) {
const res = await fetch(`https://loopling.ai/v1/video/generations/${id}`, { headers })
const gen = await res.json()
if (gen.status === 'succeeded') return gen.video // { url, expires_at, duration_seconds, … }
if (gen.status === 'failed') throw new Error(gen.error?.message ?? 'generation failed')
await new Promise((r) => setTimeout(r, 5000))
}
}
const video = await waitForVideo(generation.id)
// video.url is signed and valid for 15 minutes; read the generation again for a fresh one.Python
import time
headers = {"Authorization": f"Bearer {os.environ['LOOPLING_API_KEY']}"}
def wait_for_video(generation_id: str) -> dict:
while True:
gen = requests.get(f"https://loopling.ai/v1/video/generations/{generation_id}", headers=headers).json()
if gen["status"] == "succeeded":
return gen["video"] # {"url": …, "expires_at": …, "duration_seconds": …}
if gen["status"] == "failed":
raise RuntimeError(gen.get("error", {}).get("message", "generation failed"))
time.sleep(5)
video = wait_for_video(generation["id"])
# video["url"] is signed and valid for 15 minutes; read the generation again for a fresh one.{
"id": "vgen_01J9X6K3M8Q2",
"object": "video.generation",
"status": "succeeded",
"model": "seedance-2.5",
"created_at": "2026-09-17T08:12:44Z",
"completed_at": "2026-09-17T08:15:02Z",
"video": {
"url": "https://media.loopling.ai/…?sig=…",
"expires_at": "2026-09-17T08:30:02Z",
"content_type": "video/mp4",
"duration_seconds": 5,
"resolution": "1080p",
"aspect_ratio": "16:9",
"size_bytes": 8421376
},
"seed": 1842273,
"ink_reserved": 284,
"usd_payg_reserved": 2.84,
"ink_charged": 284,
"usd_payg_charged": 2.84,
"request": {
"model": "seedance-2.5",
"prompt": "A paper lantern drifting over a night harbour, slow dolly in",
"aspect_ratio": "16:9",
"resolution": "1080p",
"duration": 5,
"generate_audio": true
}
}
| Field | Type | Notes | |||
|---|---|---|---|---|---|
id | string | vgen_… | |||
object | "video.generation" | ||||
status | "queued" \ | "running" \ | "succeeded" \ | "failed" | Terminal states are succeeded and failed. |
progress | integer 0–100 | Present while queued / running when the vendor reports it. | |||
model | string | ||||
created_at, completed_at | ISO 8601 | completed_at is set on both terminal states. | |||
video | object | Present on succeeded. url is a signed link valid for 15 minutes; read the generation again for a fresh one. duration_seconds, resolution and aspect_ratio are present when the vendor reports them. | |||
seed | integer | The seed the vendor used, when reported. | |||
ink_reserved, usd_payg_reserved | number | The hold taken at submit, and the same at $1 = 100 Ink. | |||
ink_charged, usd_payg_charged | number | Present on terminal states: the settled charge, or 0 on failed. | |||
request | object | The normalised request, always present. | |||
error | { code, message } | Present on failed: provider_failed, timeout or result_persistence_failed. | |||
result_expired | true | Present after the 7-day retention: the record remains, the file is gone. |
Ownership follows the key's account: a generation another account created answers 404 generation_not_found.
GET /v1/video/generations/{id}/content answers 302 to a fresh signed URL for the MP4 — convenient for curl -L and for clients that would rather not parse JSON to download. Before the generation finishes it answers 409 with code: "generation_not_ready"; after the 7-day retention it answers 410 with code: "result_expired"; a failed generation answers 404 result_unavailable.
curl
# 302 to a fresh signed URL; -L follows it and -o saves the MP4.
curl -L https://loopling.ai/v1/video/generations/vgen_01J9X6K3M8Q2/content \
-H "Authorization: Bearer $LOOPLING_API_KEY" \
-o lantern.mp4Node
const res = await fetch(`https://loopling.ai/v1/video/generations/${generation.id}/content`, {
headers: { Authorization: `Bearer ${process.env.LOOPLING_API_KEY}` },
redirect: 'follow',
})
await fs.promises.writeFile('lantern.mp4', Buffer.from(await res.arrayBuffer()))Python
res = requests.get(
f"https://loopling.ai/v1/video/generations/{generation['id']}/content",
headers={"Authorization": f"Bearer {os.environ['LOOPLING_API_KEY']}"},
allow_redirects=True,
)
open("lantern.mp4", "wb").write(res.content)Signed links are valid for 15 minutes and are not authenticated beyond the signature — do not publish them; fetch the bytes and serve them yourself.
Send an Idempotency-Key header with the submit: 1–128 characters of letters, digits, ., _, : or -, unique per intent (anything else answers 400 invalid_idempotency_key). A repeated submit with the same key on the same API key within 24 hours returns the original 202 body — one reservation, one vendor task — with the header Idempotent-Replayed: true, instead of starting a second render. Use a new key for a new request.
Idempotency-Key: order-8812-hero-shotBeyond the shared error types, the video endpoints answer these code values. Everything under 400 names the field in param.
| HTTP | type | code | When |
|---|---|---|---|
| 400 | invalid_request_error | invalid_body | The body is not a JSON object. |
| 400 | invalid_request_error | unknown_model | model is not one of the three ids. |
| 400 | invalid_request_error | invalid_enum | resolution or aspect_ratio is not a known value. |
| 400 | invalid_request_error | unsupported_resolution | The model does not offer that resolution, or not with that aspect ratio. |
| 400 | invalid_request_error | invalid_duration | duration is not an integer inside the model's range. |
| 400 | invalid_request_error | unsupported_parameter | duration is "auto" or -1. |
| 400 | invalid_request_error | invalid_type | A field has the wrong type (prompt, generate_audio, metadata, a URL that is not a string, a list that is not an array). |
| 400 | invalid_request_error | too_long | prompt above 4,000 characters, a URL above 2,048, metadata above 4 KiB. |
| 400 | invalid_request_error | invalid_url | A URL is not valid https://, or carries credentials. |
| 400 | invalid_request_error | too_many | A reference list is longer than its per-kind cap for the model. |
| 400 | invalid_request_error | invalid_seed | seed outside -1…2147483647. |
| 400 | invalid_request_error | conflicting_references | Frames and reference_* inputs in one request. |
| 400 | invalid_request_error | missing_first_frame | last_frame_url without first_frame_url. |
| 400 | invalid_request_error | reference_limit | The total reference cap, or the audio-anchor rule on 2.0 / Fast. |
| 400 | invalid_request_error | missing_prompt | No prompt, no first frame, no image / video reference. |
| 400 | invalid_request_error | invalid_idempotency_key | The header does not match [A-Za-z0-9._:-]{1,128}. |
| 400 | invalid_request_error | invalid_json | The body is not valid JSON. |
| 401 | authentication_error | invalid_api_key | No, unknown or revoked key. |
| 402 | insufficient_ink_error | insufficient_ink | The wallet cannot cover the reservation; the message names required and available Ink. |
| 402 | insufficient_ink_error | key_ink_limit_reached | The key's Ink cap would be crossed. |
| 403 | permission_error | tier_gated | Video needs a paid tier or pay-as-you-go Ink. |
| 404 | not_found_error | generation_not_found | Unknown id, or another account's generation. |
| 404 | not_found_error | result_unavailable | /content on a failed generation. |
| 409 | invalid_request_error | generation_not_ready | /content before the generation finished. |
| 410 | not_found_error | result_expired | /content after the 7-day retention. |
| 413 | invalid_request_error | payload_too_large | Body above 64 KiB. |
| 415 | invalid_request_error | unsupported_media_type | The body is not application/json. |
| 429 | rate_limit_error | rate_limit_exceeded | More than 60 requests in a minute on this key. |
| 429 | rate_limit_error | concurrency_limit | 2 generations already running on the account; Retry-After: 15. |
| 429 | rate_limit_error | daily_cap_reached | The plan's daily generation cap. |
| 502 | provider_error | provider_error | The vendor refused the request; nothing charged. |
| 503 | provider_error | provider_unavailable | The video lane is not configured, or the generation could not be admitted; retry after Retry-After. |
A generation that fails after acceptance carries the reason in error on the poll response, with ink_charged: 0:
{
"id": "vgen_01J9X6K3M8Q2",
"object": "video.generation",
"status": "failed",
"model": "seedance-2.5",
"created_at": "2026-09-17T08:12:44Z",
"completed_at": "2026-09-17T08:13:10Z",
"ink_reserved": 284,
"usd_payg_reserved": 2.84,
"ink_charged": 0,
"usd_payg_charged": 0,
"request": { "model": "seedance-2.5", "prompt": "…", "aspect_ratio": "16:9", "resolution": "1080p", "duration": 5, "generate_audio": true },
"error": { "code": "provider_failed", "message": "The vendor did not complete the generation." }
}A complete submit-and-poll loop in Node and Python, and the same in curl:
curl
curl https://loopling.ai/v1/video/generations \
-H "Authorization: Bearer $LOOPLING_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance-2.5",
"prompt": "A paper lantern drifting over a night harbour, slow dolly in",
"aspect_ratio": "16:9",
"resolution": "1080p",
"duration": 5
}'Node
const res = await fetch('https://loopling.ai/v1/video/generations', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LOOPLING_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'seedance-2.5',
prompt: 'A paper lantern drifting over a night harbour, slow dolly in',
aspect_ratio: '16:9',
resolution: '1080p',
duration: 5,
}),
})
const generation = await res.json() // { id: 'vgen_…', status: 'queued', ink_reserved, … }
const headers = { Authorization: `Bearer ${process.env.LOOPLING_API_KEY}` }
async function waitForVideo(id) {
for (;;) {
const res = await fetch(`https://loopling.ai/v1/video/generations/${id}`, { headers })
const gen = await res.json()
if (gen.status === 'succeeded') return gen.video // { url, expires_at, duration_seconds, … }
if (gen.status === 'failed') throw new Error(gen.error?.message ?? 'generation failed')
await new Promise((r) => setTimeout(r, 5000))
}
}
const video = await waitForVideo(generation.id)
// video.url is signed and valid for 15 minutes; read the generation again for a fresh one.Python
import os, requests
res = requests.post(
"https://loopling.ai/v1/video/generations",
headers={"Authorization": f"Bearer {os.environ['LOOPLING_API_KEY']}"},
json={
"model": "seedance-2.5",
"prompt": "A paper lantern drifting over a night harbour, slow dolly in",
"aspect_ratio": "16:9",
"resolution": "1080p",
"duration": 5,
},
)
generation = res.json() # {"id": "vgen_…", "status": "queued", "ink_reserved": …}
import time
headers = {"Authorization": f"Bearer {os.environ['LOOPLING_API_KEY']}"}
def wait_for_video(generation_id: str) -> dict:
while True:
gen = requests.get(f"https://loopling.ai/v1/video/generations/{generation_id}", headers=headers).json()
if gen["status"] == "succeeded":
return gen["video"] # {"url": …, "expires_at": …, "duration_seconds": …}
if gen["status"] == "failed":
raise RuntimeError(gen.get("error", {}).get("message", "generation failed"))
time.sleep(5)
video = wait_for_video(generation["id"])
# video["url"] is signed and valid for 15 minutes; read the generation again for a fresh one.OpenAI SDK
from openai import OpenAI
client = OpenAI(
base_url="https://loopling.ai/v1",
api_key=os.environ["LOOPLING_API_KEY"],
)
response = client.responses.create(
model="gpt-6-astra",
instructions="You are a concise studio assistant.",
input="Give me three shot ideas for a night harbour scene.",
max_output_tokens=2048,
)
print(response.output_text)Anthropic SDK
import anthropic
# auth_token sends "Authorization: Bearer …", which is what Loopling reads.
client = anthropic.Anthropic(
base_url="https://loopling.ai/v1",
auth_token=os.environ["LOOPLING_API_KEY"],
)
message = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Give me three shot ideas for a night harbour scene."}],
)
print(message.content[0].text)loopling.ai · grows with you, grows itself
hello@loopling.ai · community · pricing · Enterprise · API · privacy · terms