Move from the Sora API
OpenAI shut down the Sora 2 models and its Videos API on 24 September 2026. The exchange serves the same three routes: create a video, read its status and download the file. Change the base URL, the API key and the model. The poll loop and the download stay the same.
The models are not Sora, so one prompt gives a different clip. Test your prompts first.
Change these four things
- Issue a
spendkey on the API keys page. See API keys. - Set the base URL to
https://router.inference.ai.exchange/v1(orOPENAI_BASE_URL). Send the key asAuthorization: Bearer sk-liquid-…(orOPENAI_API_KEY). - Change
modelfromsora-2orsora-2-proto a video model such asseedance-1-5-pro. See Model names. - Set a price cap with the
x-liquid-cap-usdheader. See Set a cap.
Then remove each Sora feature that the exchange lacks, check your length and size, and poll less often.
What differs
- Model names.
sora-2andsora-2-proreturn404 model_not_found. The exchange refuses a request withoutmodel. - Size. Many models render only their default size and refuse any
size. Remove it. - Webhooks. Send
callback_urlto get a call when the job ends, or poll the status route. See Video generation. - Not available: remix, edits, characters, thumbnails, spritesheets, list and delete. Keep the id of each video.
- Retries. The SDK tries a failed create again, and each try can buy a second video. Send an
idempotency-keyheader. A repeated create with the key starts no second job. - Money. The price is for each second of video. The exchange fixes the charge before the job starts, and a failed job costs nothing.
Example
Python
The Sora call is OpenAI().videos.create_and_poll(model="sora-2", ...). The same call for the exchange:
import os
from openai import OpenAI
client = OpenAI(
base_url="https://router.inference.ai.exchange/v1",
api_key=os.environ["LIQUID_API_KEY"],
)
video = client.videos.create_and_poll(
model="seedance-1-5-pro",
prompt="a red paper boat on a stream",
seconds="8",
size="1280x720",
poll_interval_ms=10_000,
extra_headers={"x-liquid-cap-usd": "2.00", "idempotency-key": "boat-001"},
)
if video.status == "completed":
client.videos.download_content(video.id).write_to_file("boat.mp4")
else:
print(video.error)
The model name is an illustration. The SDK marks the video methods as deprecated, but they still work.
curl
The full request, with jq to read the video id. It polls each 10 seconds, prints the result and downloads the file:
id=$(curl -s https://router.inference.ai.exchange/v1/videos \
-H "Authorization: Bearer $LIQUID_API_KEY" \
-H "x-liquid-cap-usd: 2.00" \
-H "idempotency-key: boat-001" \
-F model="seedance-1-5-pro" \
-F prompt="a red paper boat on a stream" \
-F seconds="8" \
-F size="1280x720" | jq -r '.id // error(.error.message)')
while [ "$(curl -s https://router.inference.ai.exchange/v1/videos/$id \
-H "Authorization: Bearer $LIQUID_API_KEY" | jq -r .status)" = in_progress ]; do
sleep 10
done
curl -s https://router.inference.ai.exchange/v1/videos/$id \
-H "Authorization: Bearer $LIQUID_API_KEY" | jq '{status, error, exchange}'
curl -f https://router.inference.ai.exchange/v1/videos/$id/content \
-H "Authorization: Bearer $LIQUID_API_KEY" --output boat.mp4
Reference
Model names
Send the name of a model that renders video. GET /v1/exchange/models lists each model, and a video model has video in output_modalities. The exchange does not use a different model in place of a Sora name.
The video entry gives the lengths and sizes that the offers render. For example, seedance-1-5-pro renders 4 to 12 seconds and accepts a size. A model with default_size_only: true, such as veo3.1, renders only its default size.
A chat deployment is excluded with the reason job_kind_not_served.
Set a cap
The price is for each second of video. So the cap that the exchange fixes is exactly the charge. Set x-liquid-cap-usd to the most that you agree to pay for one video. An offer whose charge is more than the cap cannot win.
The OpenAI SDK sends videos.create as a form, not as JSON. In a form, the exchange reads the exchange block only as one field that holds JSON text. It ignores fields such as exchange[cap_usd], which the SDK makes from extra_body={"exchange": {...}}. Use headers, or send the block as text:
import json
extra_body = {"exchange": json.dumps({"cap_usd": 2.0, "privacy": {"retention": "none"}})}
For several rules in one header, use x-liquid-rules. See Purchasing rules.
Length and size
| Sora setting | Exchange setting |
|---|---|
seconds: 4, 8 or 12 |
From 1 to 30, as a number or a string. An offer that declares its lengths must list the value. Default: the shortest declared length, or 4. |
size, such as 720x1280 |
<width>x<height>. An offer that declares its sizes must list the value. Without size, the provider uses its default size. |
input_reference, a file |
The image that the video starts from. Only an offer that reads image input can win. The image must have the shape of the video within 1%. See Start image in Video generation. |
input_reference, {"image_url"} |
Applied in a JSON body. The URL is https: or data:image/. {"file_id"} is refused, because the exchange has no Files API. |
For more reference inputs, such as audio or a second image, send references. See Start from reference inputs.
Poll the status
POST /v1/videos returns when a provider accepts the job. The response has status: "in_progress" and the x-liquid-cap-usd and x-liquid-job-id headers. The video id is the job id.
Send GET /v1/videos/{id} until status is completed or failed. A render takes up to several minutes, so poll each 10 to 20 seconds. create_and_poll polls each second unless you set poll_interval_ms.
When the job ends, the response holds the exchange block with the charge and video_seconds. A failed job holds error with code and message.
Send GET /v1/videos/{id}/content to download the file. The provider that rendered the video serves the file, and the exchange keeps nothing. You can download it again until expires_at, if the response states it. Only your account can read it.
Sora features
| Sora feature | What the exchange does |
|---|---|
POST /v1/videos, JSON or multipart/form-data |
Applied. prompt can have up to 32,000 characters. |
GET /v1/videos/{id} |
Applied. status is in_progress, completed or failed, never queued. progress is 0 until complete, then 100. |
GET /v1/videos/{id}/content |
Applied. 404 video_not_ready before the job is complete, 410 video_expired after the provider deletes the file. |
variant=thumbnail or spritesheet, unknown fields |
Ignored. The route sends the video. |
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
404 model_not_found |
The model is a Sora name, or a name that the exchange does not list. |
Pick a video model. See Model names. |
400, param is seconds or size |
The length is not from 1 to 30, or the size is not <width>x<height>. |
Send a value in range, such as seconds="8" and size="1280x720". |
400, param is input_reference |
The start image does not have the shape of the video, or the exchange could not read its link. | Crop or pad the image, or send a size with the shape of the image. |
400 no_eligible_offer |
The cap is less than the charge of each offer. | Raise the cap, or ask for fewer seconds. |
503 no_eligible_offer or 400 unsupported_parameter |
No offer for the model renders the length, size or reference input. | Read eligibility.excluded in the error. Change the length, remove size, or pick another model. |
402 insufficient_balance |
The charge is more than the balance. needed_usd and balance_usd give both. |
Add funds, or ask for fewer seconds. |
404 video_not_ready on the download |
The job is not complete. | Poll until completed. |
410 video_expired |
The provider deleted the file. | Download soon after the job is complete. |
503 provider_unavailable on the download |
The provider that rendered the video is not connected. Only it has the file. | Try again later. |
409 idempotency_no_result or idempotency_conflict |
A retry used the idempotency key of an earlier create. The SDK sends a new form on each try, so the body differs. | Do not create the video again. Poll the job id from the first response. See Purchasing rules. |