# 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

1. Issue a `spend` key on the **API keys** page. See [API keys](https://inference.ai.exchange/docs/keys.md).
2. Set the base URL to `https://router.inference.ai.exchange/v1` (or `OPENAI_BASE_URL`). Send the key as `Authorization: Bearer sk-liquid-…` (or `OPENAI_API_KEY`).
3. Change `model` from `sora-2` or `sora-2-pro` to a video model such as `seedance-1-5-pro`. See [Model names](#model-names).
4. Set a price cap with the `x-liquid-cap-usd` header. See [Set a cap](#set-a-cap).

Then remove each [Sora feature](#sora-features) that the exchange lacks, check your [length and size](#length-and-size), and [poll less often](#poll-the-status).

## What differs

- **Model names.** `sora-2` and `sora-2-pro` return `404 model_not_found`. The exchange refuses a request without `model`.
- **Size.** Many models render only their default size and refuse any `size`. Remove it.
- **Webhooks.** Send `callback_url` to get a call when the job ends, or poll the status route. See [Video generation](https://inference.ai.exchange/docs/video-generation.md#callback).
- **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-key` header. 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:

```python
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:

```bash
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:

```python
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](https://inference.ai.exchange/docs/rules.md#headers).

### 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](https://inference.ai.exchange/docs/video-generation.md). |
| `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](https://inference.ai.exchange/docs/reference-inputs.md).

### 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](#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](https://inference.ai.exchange/docs/rules.md#headers). |
