loopling.ai

Developer API

Video generations API · loopling

Guide · Video · Responses · Messages · Billing & Ink

Overview

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.

StepCall
SubmitPOST /v1/video/generations202 with id and status: "queued"
PollGET /v1/video/generations/{id} until status is succeeded or failed
Downloadvideo.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.

Submit

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.

Request schema

FieldTypeRequiredNotes
model"seedance-2.5" \"seedance-2.0" \"seedance-2.0-fast"yesunknown_model otherwise.
promptstringnoUp 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"noDefault "16:9". "adaptive" follows the first frame or reference.
resolution"480p" \"720p" \"1080p"noDefault "720p". seedance-2.0-fast has no 1080p; seedance-2.5 refuses 1080p with "9:16" or "adaptive" — both answer unsupported_resolution.
durationinteger secondsnoDefault 5. 430 for seedance-2.5; 415 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_audiobooleannoDefault true.
seedintegerno-1 to 2147483647; a reproducibility hint the vendor may ignore.
first_frame_urlhttps URLnoStart frame. Image; publicly fetchable.
last_frame_urlhttps URLnoEnd frame; requires first_frame_url (missing_first_frame).
reference_image_urlshttps URL[]noStyle / subject references.
reference_video_urlshttps URL[]noMotion references.
reference_audio_urlshttps URL[]noAudio references. On seedance-2.0 / -fast an audio reference needs an image or video beside it; seedance-2.5 accepts audio alone.
metadataobjectnoUp 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.

Poll

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.

Response schema

{
  "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
  }
}
FieldTypeNotes
idstringvgen_…
object"video.generation"
status"queued" \"running" \"succeeded" \"failed"Terminal states are succeeded and failed.
progressinteger 0–100Present while queued / running when the vendor reports it.
modelstring
created_at, completed_atISO 8601completed_at is set on both terminal states.
videoobjectPresent 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.
seedintegerThe seed the vendor used, when reported.
ink_reserved, usd_payg_reservednumberThe hold taken at submit, and the same at $1 = 100 Ink.
ink_charged, usd_payg_chargednumberPresent on terminal states: the settled charge, or 0 on failed.
requestobjectThe normalised request, always present.
error{ code, message }Present on failed: provider_failed, timeout or result_persistence_failed.
result_expiredtruePresent 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.

Download

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

Node

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.

Idempotency

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-shot

Errors

Beyond the shared error types, the video endpoints answer these code values. Everything under 400 names the field in param.

HTTPtypecodeWhen
400invalid_request_errorinvalid_bodyThe body is not a JSON object.
400invalid_request_errorunknown_modelmodel is not one of the three ids.
400invalid_request_errorinvalid_enumresolution or aspect_ratio is not a known value.
400invalid_request_errorunsupported_resolutionThe model does not offer that resolution, or not with that aspect ratio.
400invalid_request_errorinvalid_durationduration is not an integer inside the model's range.
400invalid_request_errorunsupported_parameterduration is "auto" or -1.
400invalid_request_errorinvalid_typeA field has the wrong type (prompt, generate_audio, metadata, a URL that is not a string, a list that is not an array).
400invalid_request_errortoo_longprompt above 4,000 characters, a URL above 2,048, metadata above 4 KiB.
400invalid_request_errorinvalid_urlA URL is not valid https://, or carries credentials.
400invalid_request_errortoo_manyA reference list is longer than its per-kind cap for the model.
400invalid_request_errorinvalid_seedseed outside -12147483647.
400invalid_request_errorconflicting_referencesFrames and reference_* inputs in one request.
400invalid_request_errormissing_first_framelast_frame_url without first_frame_url.
400invalid_request_errorreference_limitThe total reference cap, or the audio-anchor rule on 2.0 / Fast.
400invalid_request_errormissing_promptNo prompt, no first frame, no image / video reference.
400invalid_request_errorinvalid_idempotency_keyThe header does not match [A-Za-z0-9._:-]{1,128}.
400invalid_request_errorinvalid_jsonThe body is not valid JSON.
401authentication_errorinvalid_api_keyNo, unknown or revoked key.
402insufficient_ink_errorinsufficient_inkThe wallet cannot cover the reservation; the message names required and available Ink.
402insufficient_ink_errorkey_ink_limit_reachedThe key's Ink cap would be crossed.
403permission_errortier_gatedVideo needs a paid tier or pay-as-you-go Ink.
404not_found_errorgeneration_not_foundUnknown id, or another account's generation.
404not_found_errorresult_unavailable/content on a failed generation.
409invalid_request_errorgeneration_not_ready/content before the generation finished.
410not_found_errorresult_expired/content after the 7-day retention.
413invalid_request_errorpayload_too_largeBody above 64 KiB.
415invalid_request_errorunsupported_media_typeThe body is not application/json.
429rate_limit_errorrate_limit_exceededMore than 60 requests in a minute on this key.
429rate_limit_errorconcurrency_limit2 generations already running on the account; Retry-After: 15.
429rate_limit_errordaily_cap_reachedThe plan's daily generation cap.
502provider_errorprovider_errorThe vendor refused the request; nothing charged.
503provider_errorprovider_unavailableThe 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." }
}

Examples

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)

API overview

loopling.ai · grows with you, grows itself

hello@loopling.ai · community · pricing · Enterprise · API · privacy · terms