Media API quickstart
Use your API key to quote, create, replay, poll, download, and reconcile image, video, and music tasks.
Images, videos, and music are available through https://api.uniroute.app, using the same unified API key as LLM requests. Each task settles against your USD wallet and consumes the key's shared spending limit.
In key management, create or select a valid unified key: use automatic channels, with no group binding or channel restrictions. Your account needs available USD wallet funds, and the key needs sufficient total and time-window allowance. Reuse an existing valid key; you do not need a new key, a limit reset, or a new top-up to rerun this tutorial. See authentication and permissions.
Run the full workflow with the standalone Python example
You need Python 3.9 or later and curl on macOS, Linux, or WSL. No third-party Python dependencies are required. The client uses system file locks and does not support native Windows Python. Store your key in a file only you can read, such as ~/.config/uniroute/api-key. Do not put it in commands, public logs, or a code repository. If you already have a key file, use its existing path.
chmod 600 ~/.config/uniroute/api-key
curl --fail --proto '=https' --proto-redir '=https' \
https://uniroute.app/examples/uniroute-media.py -o uniroute-media.py
python3 uniroute-media.py \
--key-file ~/.config/uniroute/api-key \
--budget-usd 0.5 \
--state ./media-run.json \
--output ./media-output \
--kind all \
--verify-idempotency
--budget-usd is the combined ceiling for the tasks selected in this run. Set it to an amount within your remaining spending budget. The client requests one new quote for each selected media type and creates no tasks if the combined quote exceeds that ceiling. The combined quote describes the expected cost of this run; it does not top up your wallet or add key allowance.
By default, --kind all runs images, videos, and music in sequence. You can use image, video, or music instead. The example checks models, options, and providers in the current catalog, saves the exact quote as max_cost_usd, and replays once with the same idempotency key and identical request body to confirm an unchanged task ID. It then polls until generation succeeds and billing is charged, downloads all outputs, and reads this key's history and actual spending change.
| Type | Example model | Option | Example quote per task, USD |
|---|---|---|---|
| Image | z-image | 16:9 | 0.0056 |
| Video | bytedance/seedance-1.5-pro | 480p:5 | 0.05 |
| Music | ai-music-api/generate | acoustic, folk, warm | 0.084 |
These example prices total 0.1396. Models, options, and prices may change; always use your own /v1/media/quote response. The client does not blindly select a replacement when a recommended model is unavailable.
The program prints task IDs, filenames, and costs, without printing the key. Each download is limited to 64 MiB, with a combined limit of 128 MiB. It downloads only HTTPS URLs whose resolved IPv4 and IPv6 addresses are all public. Connections are pinned to the verified address and validate the original hostname's certificate, without a Bearer token. On a redirect, the client stops and keeps the task ID for inspection. Output filenames are generated from task IDs rather than remote filenames.
Recover after an interruption
Keep media-run.json and the downloaded files. Rerun using the same command, key, budget, and state file. When a task ID is known, the client only queries the original task: it does not quote again, recreate, or replay a POST. Completed tasks do not incur a new charge. By default, the client waits up to 1800 seconds per task; use --wait-seconds 3600 to extend this.
If a creation request is interrupted before the task ID arrives, the program records an unknown state and stops. Do not delete the state file or recreate the task with a new idempotency key. Keep the original request and idempotency key, then query this key's history or contact support to confirm whether it was accepted. released_unknown also does not mean there was definitely no upstream cost. See billing and recovery. Before reading state, the client acquires a nonblocking exclusive lock on that state file for all calls and downloads. A second process stops rather than creating another task. Do not use separate state files to spend the same budget concurrently: the client cannot combine budgets from separate runs.
Inspect the real interfaces with curl
The following commands require Bash, curl, jq, and Python 3. Run them in order in the same Bash session. set -euo pipefail stops immediately if a quote, creation request, or JSON check fails; do not skip a failed step and continue with replay. Authentication headers are stored in a temporary private file, keeping the raw key out of curl arguments. You can remove that temporary header file, but keep the request body, idempotency key, and task ID for recovery.
set -euo pipefail
umask 077
KEY_FILE="$HOME/.config/uniroute/api-key"
BASE='https://api.uniroute.app'
AUTH_HEADER="$(mktemp)"
printf 'Authorization: Bearer %s\n' "$(cat "$KEY_FILE")" > "$AUTH_HEADER"
trap 'rm -f "$AUTH_HEADER"' EXIT
curl --fail-with-body --proto '=https' -H @"$AUTH_HEADER" \
"$BASE/v1/media/catalog" -o catalog.json
jq '.data | {enabled, currency, policy_version, models: [.models[] | {modelId, mediaKind, provider, unit, param, input_fields}]}' catalog.json
The catalog uses camelCase fields modelId and mediaKind, plus param.options[].value. Creation uses snake_case media_type, and the model's option must be a value from the catalog. Some models include input_fields and require inputs such as an external image; this tutorial chooses models without external references. A catalog unit price is not necessarily the total cost, and videos may be billed per second.
Request a quote and save the exact request
curl --fail-with-body --proto '=https' -H @"$AUTH_HEADER" \
-H 'Content-Type: application/json' \
-d '{"model":"z-image","option":"16:9"}' \
"$BASE/v1/media/quote" -o quote.json
jq '.data' quote.json
jq -e '.code == 0 and .data.currency == "USD" and .data.policy_version == "usd-wallet-v1" and (.data.quoted_usd | type == "string")' quote.json
jq '.data | {media_type:"image", model:.model, option:.option, prompt:"A quiet lake at sunrise, watercolor illustration.", max_cost_usd:.quoted_usd}' quote.json > request.json
python3 -c 'import uuid; print(uuid.uuid4())' > idempotency-key.txt
Before continuing, check the quote, model, option, and your remaining budget. Both quoted_usd and max_cost_usd are exact decimal strings. Do not convert them to floating-point numbers or estimate them from catalog unit prices. For a batch, add all quotes first; checking that each task is individually below the budget is insufficient. This manual curl example creates one image only.
Create and replay the same request
curl --fail-with-body --proto '=https' -H @"$AUTH_HEADER" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(cat idempotency-key.txt)" \
--data-binary @request.json "$BASE/v1/media/tasks" -o created.json
jq -e '.code == 0 and (.data.task_id | type == "string")' created.json
jq -r '.data.task_id' created.json > task-id.txt
# Creation and replay both return HTTP 202. Wait at least 7 seconds;
# replay also counts against the creation rate limit.
sleep 7
curl --fail-with-body --proto '=https' -H @"$AUTH_HEADER" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(cat idempotency-key.txt)" \
--data-binary @request.json "$BASE/v1/media/tasks" -o replay.json
jq -e --arg id "$(cat task-id.txt)" '.data.task_id == $id' replay.json
Media APIs return {code:0,message:"...",data:{...}}. The same API key, idempotency key, and identical request body should return the same task ID. Reusing an idempotency key with a different body causes a conflict. If a POST fails or times out, stop this curl workflow; do not automatically retry or change the idempotency key.
Poll, download, and reconcile
# While not terminal, repeat this GET every 5 seconds using the saved task ID.
curl --fail-with-body --proto '=https' -H @"$AUTH_HEADER" \
"$BASE/v1/media/tasks/$(cat task-id.txt)" -o task.json
jq '.data | {task_id, status, billing, urls}' task.json
jq -e '.data.status == "success" and .data.billing.state == "charged"' task.json
# Download only after the check above succeeds. Do not send API auth headers.
URL="$(jq -r '.data.urls[0].url' task.json)"
curl --fail --location --proto '=https' --proto-redir '=https' \
"$URL" -o image-output.png
curl --fail-with-body --proto '=https' -H @"$AUTH_HEADER" \
"$BASE/v1/media/tasks?limit=20" -o history.json
jq '.data | {tasks, next_cursor}' history.json
curl --fail-with-body --proto '=https' -H @"$AUTH_HEADER" \
"$BASE/v1/usage" -o usage.json
jq '{mode, quota, usage}' usage.json
urls is an object array, for example [{"url":"https://...","kind":"image"}]. Music may have multiple outputs; download all of them. Paginate history using the returned next_cursor: curl ... --get --data-urlencode "limit=20" --data-urlencode "cursor=$CURSOR" "$BASE/v1/media/tasks".
/v1/usage returns raw JSON with no data wrapper. For a key with a total limit, read quota.used; without a total limit, read usage.total.actual_cost. usage.total.cost is a reference amount before rate adjustments and cannot replace the actual charge. The wallet balance is shown in the console. The complete example prints each task's charged_usd and the usage change. Other concurrent requests on the same key are also included in that change.
Continue with images, videos, music, and error handling.
UniRoute