CreativeLab API v2: one endpoint for video, images and music

CreativeLab API v2: one endpoint for video, images and music

In v1 the generation settings lived inside the model identifier. Need 1080p video, five seconds long? That's seedance-1-pro-1080P-5s. Need the same five seconds at 720p? A different id, which you first have to find in the catalogue. Resolution, duration and quality multiplied out, the catalogue grew, and the integration turned into a lookup table mapping "user settings → id string".

We rewrote the media API. v2 isn't new addresses for the old methods, it's a different contract: parameters moved into the request body, the price can be checked before generation, and a retry after a network timeout no longer creates a second task. v1 keeps working on the same key, but it is frozen — new capabilities ship in v2 only.

One request instead of hunting for a tier

JSON
// v1: settings baked into the id, nothing to tune
POST https://gptunnel.ru/v1/media/create
{ "model": "seedance-1-pro-1080P-5s", "prompt": "cat", "ar": "16:9" }
JSON
// v2: base model, settings in params
POST https://gptunnel.ru/api/v2/media/tasks
{
  "model": "seedance-1-pro",
  "prompt": "cat",
  "params": { "aspect_ratio": "16:9", "resolution": "1080P", "duration": 5 }
}

The difference isn't cosmetic. v1 reads only the aspect ratio (ar) from the request; everything else falls back to the model defaults, so part of what a model can do simply isn't reachable through v1. Tiered ids like seedance-1-pro-1080P-5s will never show up in the v2 catalogue: there you get the base model plus the list of its parameters.

The base URL changes from https://gptunnel.ru/v1/media to https://gptunnel.ru/api/v2/media. Same key, same Authorization header — no new credentials needed.

The catalogue ships the parameter schema

You don't have to guess what a given model accepts, it's described in machine-readable form:

bash
curl https://gptunnel.ru/api/v2/media/models \
  -H 'Authorization: YOUR_API_KEY'
JSON
{
  "id": "kling-v3",
  "type": "VIDEO",
  "price": 210,
  "prompt": "required",
  "params": [
    { "key": "resolution", "type": "enum", "default": "1080p",
      "options": [{ "value": "720p" }, { "value": "1080p" }] },
    { "key": "duration", "type": "number", "default": 5,
      "min": 5, "max": 10, "step": 5, "integer": true, "unit": "s" }
  ],
  "inputs": [
    { "key": "image", "kind": "image", "role": "first_frame",
      "required": false, "count": { "min": 0, "max": 1 },
      "formats": ["jpg", "png", "webp"] }
  ]
}

That response is enough to build the settings form in your own UI: params says what can be set (type, allowed values, default, bounds), inputs says which files the model takes, in which role, how many and in what formats. The prompt field, with values required | optional | none, fixes an old v1 annoyance where a prompt was mandatory even for models that don't need one.

One thing worth remembering: params only publishes what the client is allowed to set. A model may also have fixed parameters — visible in the task object and in the price response, but not changeable.

Price before generation, not after the charge

Price depends on more than the model: seconds of video, resolution, quality. In v1 the catalogue carried one price per id, and there was nowhere to compute a specific combination.

POST /api/v2/media/price takes the same { model, params } pair as task creation and returns the final number without launching anything and without charging:

JSON
{ "code": 0, "model": "kling-v3", "price": 210,
  "params": { "resolution": "1080p", "duration": 5, "aspect_ratio": "16:9" } }

The price is computed by the same function that performs the actual charge, so "quoted one number, billed another" is not possible here. Two practical consequences. First, params in the response are the applied values: what you sent plus defaults plus the model's fixed parameters — if a parameter you sent isn't in the response, it isn't configurable. Second, the same call is a convenient way to validate a request body before running it for real: same validation, no money involved. One caveat: models that are also billed by prompt or input files (tokens, megapixels) return the price without that part.

A retry no longer costs money

The classic scenario: the client hits a network timeout, repeats the request, two tasks get created, the account is charged twice. Task creation now takes an idempotency_key:

JSON
POST /api/v2/media/tasks
{
  "model": "seedance-1-pro",
  "prompt": "cat",
  "params": { "aspect_ratio": "16:9", "resolution": "1080P", "duration": 5 },
  "idempotency_key": "order-1042",
  "webhook_url": "https://your.app/hooks/media"
}

A repeat with the same key returns the same task. If the same key arrives with a different body, the answer is 409 (code: 23) rather than a silent duplicate — the server compares a hash of the request, not just the key.

The key must be tied to a business operation: order id, message id, queue row id — not regenerated on every attempt. A random key per retry protects against nothing.

One task object instead of three response shapes

POST /api/v2/media/tasks creates a task for any media model, and the response is the same everywhere: id, status (queued | running | done | failed), params, price, result. GET /api/v2/media/tasks/:id returns the same object, and the same object goes to the webhook. In v1 the shape depended on the model family: ordinary generations returned the result as a string in url, music returned an array with its own field names, and some models lived on separate branches altogether.

The main trap when porting is reading result[0]. In v2 result is always an array and it may hold more than one file: models with multiple outputs return all of them, and music models (Suno, Mureka, MiniMax) return tracks, each with its own url, title, duration, cover_url and lyric.

JavaScript
if (task.status === 'done') {
  for (const item of task.result) save(item.url)
}

For fast models there's a short synchronous mode: wait: true in the create body holds the connection until the task finishes and returns the final object straight away. The wait is capped at roughly 30 seconds; if the task doesn't make it, you get the object in its current status and fetch the result the usual way. No need to write polling for an image that takes six seconds to draw. For video, wait is pointless — use a webhook or polling. In v1 the synchronous mode was a separate endpoint, /v1/media/generate, and worked with exactly one model.

Errors: a code to branch on, a title to be precise

On success the HTTP status is 200 and code is 0; on failure you get a meaningful status and a machine-readable code in the body. The catalogue of codes grew: 20 — model not found, 22 — task not found, 23 — idempotency conflict, 25 — input file rejected, 26 — prompt rejected, 27 — parameter combination not supported, 28 — transient failure, retrying makes sense. Codes shared with v1 (3, 5, 99, 1001) kept their meaning, so existing handlers don't need renumbering.

Previously a failure reason almost always arrived as code: 1: the catalogue covered five reasons out of forty-something, and the rest collapsed into "internal error". Now a failure carries a code plus error.title, the exact machine-readable reason: ERR_IMAGE_TOO_LARGE and ERR_UNSUPPORTED_FILE_FORMAT both map to code: 25 but differ in title. New reasons are added to title without touching the code table, so branching on it in production is the safer bet. Internal errors carry a trace_id — support finds the request with it immediately.

If your code branches on error.code when status: failed, re-read those handlers: some failures that used to arrive as 1 now come as 2528, 1001 or 1002.

What to change in the code

v1v2
POST /v1/media/createPOST /api/v2/media/tasks
GET /v1/media/result?task_id=…GET /api/v2/media/tasks/:id
GET /v1/media/modelsGET /api/v2/media/models
POST /v1/media/generate (single model)wait: true in the create body
POST /api/v2/media/price
ar: "16:9"params.aspect_ratio (name comes from the model schema)
images: ["https://…"]inputs: { "<role>": ["https://…"] }
callback_urlwebhook_url
url in the responseresult[].url
code/message at the task rooterror.code, error.title, error.message
modelration in the cataloguemoderation

One more adjustment: strictness. In v2 the request body is validated strictly — an unknown top-level field is rejected with code: 3 and its name is listed in details. This is exactly the case where idempotencyKey instead of idempotency_key silently disabled double-charge protection, and a leftover ar from v1 replaced the aspect ratio with a default — all of it answering 200 OK. Inside params the rule is softer: a key the model doesn't know doesn't fail the request, it comes back in ignored_params with a reason. What remains an error is an invalid value for a parameter the model does accept — duration: 999 against a limit of 10 gives a 400, not a silent substitution.

What matters in production

  • Result links live for 48 hours. Need the file longer — pull it to your own storage right after done.
  • Webhooks are delivered at least once. On failure we retry after roughly 5, 20, 60 and 180 seconds, so the handler must be idempotent on the task id. The signature sits in the X-Gptunnel-Signature header in the form t=<unix>,v1=<hex>: it's HMAC-SHA256 over the string "<t>.<raw body>" with the API key as the secret. The endpoint must be a publicly reachable HTTPS address.
  • Files can be sent inline. inputs accepts public links and data: URLs alike — up to 20 MB per file and 50 MB per request body. A broken file is rejected before the task is created and before any charge, with code: 25.
  • 28 is the only code worth retrying automatically. Other 4xx responses won't change on a second attempt.
  • Retired models don't break the integration. A model with an announced retirement carries deprecated_at and deprecation_redirect_to in the catalogue. After that date the task automatically runs on the replacement, billing follows the requested model, and the swap is reported by the Deprecation, Sunset, x-model-requested and x-model-served headers. With no replacement, creation returns 410 with code: 21.

Migration can be partial: v1 and v2 run side by side on the same key, so it makes sense to move one scenario, watch the metrics, and continue.

The full specification is in the docs: About CreativeLab API, Model list, Create task, Price, Webhooks and Error reference.