Skip to main content
Video is an asynchronous job, in OpenAI’s Sora shape. You create a job, poll it until the provider reports it finished, then download the bytes. It takes the same key and the same provider/model ids as chat completions, draws on the same balance, and lands in Activity and on your statement like any other request. There are four doors: create, read, content and delete. Listing jobs is not served and keeps its 404, and neither is remix. Point your existing OpenAI client at https://api.hopscotchlabs.ai and give it a Hopscotch API key. Nothing else about your code changes.
Base URL

How to

Create a video

POST /v1/videos starts one job and answers the provider’s job object for it, with external URLs omitted. The body may be JSON or multipart form data. Send JSON for text to video. Send multipart when the create carries an input_reference image, which rides in the form as a file part under that name. The body is capped at 8 MiB either way; a larger body is refused 413 before any hold is taken.
A multipart create names the same fields, with the image as a file part:
curl
The five fields:
  • model is required. It takes the same three forms as any other door: a provider-pinned id, a routing-profile slug, or the workspace default. A bare id is refused 400 with the code model_id_not_pinned. GET /v1/models lists the exact strings this key can call, and a model is offered only while it would serve on call.
  • prompt is required for text to video. A model that renders from an image permits an omitted prompt when input_reference is uploaded. Each model states its own length limit; the table below lists the stated ones.
  • seconds is whole seconds of video, 1 or more, as decimal digits such as "8"; JSON also accepts a positive safe integer. Absent, it is 4. An explicit null is refused. Each model accepts only the durations it publishes: a create naming anything else is refused 400 before anything is held.
  • size is required, as WIDTHxHEIGHT in pixels, such as "1280x720". It must have a per-second rate on the model; a size without one is refused before anything is held, and so is a size the model does not publish. That refusal answers 503 with the code model_not_priceable: an unpublished size, such as 1366x768 on a Seedance model, is not a fault that clears, so do not retry it. Send a size the model publishes.
  • input_reference is a reference image, multipart only. A model that renders from an image only requires it; a model that renders from text only refuses it. Each model accepts only the image forms and sizes it publishes, listed below.
A refusal the provider made about the request is relayed with the provider’s own status and its own sentence, so a parameter it requires and did not get reads as what it is rather than as a fault on this platform.

Durations, sizes and sound per model

Match the model half of your pinned id against the first column. GET /v1/models lists the exact strings this key can call, and that list is authoritative where this table and the catalog disagree. Unknown means the serving code states no rule; the provider’s own refusal arrives relayed. A row marked not currently served names a model no live id serves today, so check the catalog before calling one. Where a model requires a duration, omitting seconds does not fall back to 4: the create is refused 400 and nothing is held.

First frame and last frame

There is no last-frame field: the request carries five fields and none names a last frame, so no served model takes one. The first frame is input_reference: on a model that accepts it, the uploaded image becomes the first frame of the render. Whether a model requires, permits or refuses the image is in the table above.

The life of a job

A create takes a hold of seconds (4 when absent) times the model’s per-second rate for the requested size, before anything is sent to the provider. On a workspace-owned credential the hold is zero. On a Seedance model the hold covers one second more than requested, because the render is billed a frame past the seconds; what the job does not use is returned when it settles. The hold stays open until the job ends. Poll the job with GET /v1/videos/{id}:
curl
The answer is the provider’s current job object: id, status (queued, in_progress, completed, failed or cancelled), progress, and the seconds and size the job carries. Its model field is rewritten to the public id the create returned, and external URLs are omitted. A terminal status seen on a poll settles the job’s money once; the read itself writes no usage row and is charged nothing. One usage row is written per job, when it settles, timestamped then. What the job settles at:
  • Completed: the seconds the provider reports, never above the hold. A job reporting zero seconds releases the hold and records served usage at zero charge; absent or unreadable seconds charge the full hold.
  • Failed, or cancelled with the provider’s confirmation: nothing.
  • Still open 3 hours after create: expired, charged its full hold. You do not need to poll for the job to settle, but poll to learn the outcome.
Only the account that created a job can read it, fetch its content or cancel it. Any other key, and an id that never existed, get the same 404 with the code video_not_found, and the provider is not called. When the provider reports the job completed, download the bytes:
curl
The content door streams the provider’s bytes with their content type. The query string is forwarded, so a variant rides on the URL. This platform stores no video, so the provider’s own download window applies. Do not expect length or disposition headers: the observed answer is 200 video/mp4, chunked, with no Content-Length and no Content-Disposition. Asking for content before the job completes answers 409 with the code video_not_completed. To cancel, delete the job:
curl
The delete is forwarded to the provider as a cancellation or deletion request, and its answer is relayed. The hold is released at no charge only when the provider’s answer confirms the job cancelled; an answer reporting it completed is charged as completed; an answer stating no status leaves the full hold open until a terminal status is observed or the 3 hour deadline expires it at the full hold. Deletion alone does not confirm cancellation. Two Wan 2.2 models are the exception to the follow-ups above: content and delete answer 400 there, because the provider serves neither through this door. The source specification says a finished Wan job’s poll answer supplies the expiring video_url instead, but the control plane strips every external URL from every job object before answering, a behavior the video-doors suite pins, so the handler wins and the specification sentence is stale. The effect is that the bytes of a finished Wan 2.2 job cannot be fetched through this API today; its poll still reports the job. A deepinfra/p-video job is a narrower exception on the delete alone: the provider cannot cancel, so its delete answers 405 with the type invalid_request_error, and the job keeps running and settles normally.

Refusals

A create for a model the catalog does not list answers 404 model_not_found, like any other door. A follow-up for a job this account did not create answers 404 video_not_found. Content for a job that is not completed answers 409 video_not_completed. A provider that cannot be reached answers 502 provider_unavailable, and an answer that cannot be read answers 502 invalid_provider_response; neither charges anything. See Errors for the envelope every one of these arrives in.

Next

Streaming

Read a chat reply as it is generated.

Credits and billing

How a hold becomes a charge.
Every response carries the same id on two headers: x-hopscotch-request-id, which is ours, and x-request-id, which is the one the official OpenAI SDKs surface on their error objects. Every error envelope repeats it as request_id. Quote it when you ask us about a request. Without it we are guessing at which of your requests you mean.