Queue
Submit a job, poll it or stream it, fetch the result. The asynchronous half of the API.
A job is submitted, not awaited. POST /v1/queue/{model} accepts the work, reserves what it could cost and answers at once with a request_id and the three URLs that follow it (status, result and cancel), so nothing has to hold a connection open for minutes. Any model can be run this way, and video is always run this way, no browser, proxy or load balancer will keep a connection alive for a generation that takes that long. POST /v1/videos/generations and POST /v1/videos are the video-shaped spellings of the same submit; they put the job on this queue and answer with the same request_id.
The routes
| METHOD | PATH | WHAT IT DOES |
|---|---|---|
| POST | /v1/queue/{model} | Submit a job. Returns immediately. |
| GET | /v1/queue/requests/{job_id}/status | Poll: status, outcome, queue position, timings, optional logs. |
| GET | /v1/queue/{model}/requests/{job_id}/status | fal's own path shape. Same handler, same answer. |
| GET | /v1/queue/requests/{job_id} | The result. |
| GET | /v1/queue/{model}/requests/{job_id} | Same. |
| PUT | /v1/queue/requests/{job_id}/cancel | Cancel, and release the reservation. |
| GET | /v1/queue/requests/{job_id}/stream | Server-sent status transitions, instead of polling. |
| POST | /v1/run/{model} | Submit and wait, for callers that would rather block than poll. |
| GET | /v1/jobs | List this account's jobs. Ours. Fal has no equivalent. |
The model is a path segment
A model slug contains a slash, so it travels as a wildcard rather than one segment: POST /v1/queue/fal/veo3 submits to fal/veo3, and everything between /v1/queue and the query string is the slug. A caller with no path to spend (the MCP submit_job tool is the one that matters) may send model in the body instead. The path wins when both are present, so a body field can never redirect a request to a model the URL was not authorised against.
The whole lifecycle
BASE="https://api.routehook.ai/v1"
AUTH="Authorization: Bearer $ROUTEHOOK_API_KEY"
# 1. Submit. Answers as soon as the job is accepted and the money is held.
JOB=$(curl -s -X POST $BASE/queue/fal/veo3 \
-H "$AUTH" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: harbour-dawn-001" \
-d '{ "prompt": "drone shot over a harbour at dawn", "duration": 5 }' \
| jq -r .request_id)
# 2. Poll. COMPLETED is terminal whatever the outcome, so this loop ends.
while [ "$(curl -s $BASE/queue/requests/$JOB/status -H "$AUTH" | jq -r .status)" \
!= "COMPLETED" ]; do
sleep 3
done
# 3. Fetch. A failed job answers the error envelope here, not a result.
curl -s $BASE/queue/requests/$JOB -H "$AUTH" | jq '{ outcome, payload, cost }'
# 4. Or give up on it, any time before it finishes.
curl -s -X PUT $BASE/queue/requests/$JOB/cancel -H "$AUTH"
Submit
The body is the model's own input object. fal takes a model's parameters at the top level (prompt, image_url, num_inference_steps, aspect_ratio, whatever that particular model accepts), and yours is forwarded upstream unchanged, because the sets differ per model and change whenever a vendor ships. Seven names are read by the gateway before the body is forwarded: model, prompt, n, size and duration, because the reservation is computed from them, and webhook_url, because it is an instruction to us rather than to the model. webhook_url is stripped from what is forwarded; nothing upstream ever receives it.
POST/v1/queue/{model}Available
Submit a job and return immediately with the id and the URLs that follow it.
What a submit answers
{
"request_id": "job_ab12cd34",
"status": "IN_QUEUE",
"queue_position": 3,
"status_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/status",
"response_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34",
"cancel_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/cancel"
}
Idempotency
A submit that times out on the client is indistinguishable from one that never arrived, and retrying it blindly opens a second job against a second reservation. Send an Idempotency-Key header (any string unique to the logical request, reused across every attempt at it), and a repeat submit returns the job that already exists instead of starting another. It is a header rather than a body field because it describes the delivery of the request rather than the job, so it still matches when a client library has rebuilt the body.
Poll the status
The status route answers the same body whether you poll it or stream it. outcome is null until the job is terminal, and error is filled in on a failure so a poller learns why without a second round trip.
GET/v1/queue/requests/{job_id}/statusAvailable
Poll a job: queue position, terminal outcome, timings and optional logs.
{
"request_id": "job_ab12cd34",
"status": "IN_PROGRESS",
"outcome": null,
"queue_position": null,
"logs": [
{
"message": "sampling frame 12/120",
"level": "INFO",
"timestamp": "2026-08-23T11:02:47Z"
}
],
"metrics": { "inference_time": null, "queue_time": 4.2 },
"error": null
}
queue_position, and why null is not zero
queue_position counts the jobs still ahead of this one, and goes null the moment this one leaves the queue. A running job that kept reporting 0 would read as about to start rather than already started, which is the wrong thing to put in front of someone watching a progress indicator. Treat a number as a position and null as no longer waiting.
Logs
Logs are gated behind a query parameter, exactly as fal gates them: add ?logs=1 and the job's own output comes back as an array of message, level and timestamp. level is whatever the upstream labelled the line (INFO, ERROR, STDOUT), and is not constrained to a fixed set on purpose, because one unrecognised label must never fail the response of a job that succeeded. Logs are diagnostic. Do not parse them for control flow; that is what outcome is for.
Stream the transitions instead
GET /v1/queue/requests/{job_id}/stream answers text/event-stream and pushes each transition as it happens. Every frame carries exactly the status body above, so one parser serves both routes, and the stream closes when the job reaches a terminal state. Use it instead of a polling loop, not alongside one. A client doing both pays twice for the same information.
GET/v1/queue/requests/{job_id}/streamAvailable
Server-sent stream of a job's status transitions, instead of polling.
data: {"request_id":"job_ab12cd34","status":"IN_QUEUE","outcome":null,"queue_position":3}
data: {"request_id":"job_ab12cd34","status":"IN_PROGRESS","outcome":null,"queue_position":null}
data: {"request_id":"job_ab12cd34","status":"COMPLETED","outcome":"succeeded","queue_position":null}
Fetch the result
payload is the upstream's own result body, forwarded verbatim: { images: [...] } from an image model, { video: { url } } from a video one, whatever that model returns. It is deliberately not normalised: there is one shape per model, a normalisation would be a guess for every model discovered after it was written, and for the ones it guessed right about it would break the destructuring already written against fal. cost is what you were charged as a decimal string, reference_value is what the same job costs at the vendor's list price, and gateway_request_id is the req_… row this job wrote to the usage log.
GET/v1/queue/requests/{job_id}Available
Fetch the output of a finished job.
{
"request_id": "job_ab12cd34",
"model": "fal/veo3",
"created": 1786312455,
"outcome": "succeeded",
"payload": {
"video": { "url": "https://cdn.routehook.ai/vid/ab12cd34.mp4" }
},
"gateway_request_id": "req_7c41d9be",
"cost": "0.4200",
"reference_value": "0.7500",
"usage": { "video_seconds": "5" }
}
A failed job answers an error, not a result
This route does not answer 200 with an error field. A job whose outcome is failed answers the published envelope (error with code, message and request_id) under the status code that matches the code, which is exactly what a fal client already handles. That is what makes the status deviation safe: the failure still reaches the client, through the route the client already fails on. The same failure is also readable inline on the status route, if you would rather learn about it while polling than on the fetch.
{
"error": {
"code": "upstream_unavailable",
"message": "fal could not generate this: upstream returned 500.",
"request_id": "req_7c41d9be"
}
}
Cancel
Cancelling is idempotent, and a job that had already finished answers 200 rather than a conflict. A client that cancels is usually reacting to a timeout it cannot tell apart from a lost response, so making it distinguish I stopped it from it had already stopped adds an error path carrying no information: either way the job is not running and nothing further will be charged. canceled says whether this call is what stopped it, and outcome is null for the moment between the cancellation being recorded and the worker abandoning an attempt already in flight upstream.
PUT/v1/queue/requests/{job_id}/cancelAvailable
Cancel a job that has not finished and release its hold.
{
"request_id": "job_ab12cd34",
"status": "COMPLETED",
"outcome": "canceled",
"canceled": true
}
Wait, if you would rather not poll
POST /v1/run/{model} is the same machinery with the wait folded in: it takes the submit body, blocks, and answers with the result body above. It is the right shape for a script and the wrong shape for a browser. Past a couple of minutes you are beyond what most proxies in between will hold, and a connection they drop looks exactly like one we dropped. There is a ceiling on the wait, set by the operator and fifteen minutes by default; reaching it ends the wait and not the job, and the call answers with the request_id so you can poll it the ordinary way.
POST/v1/run/{model}Available
Submit a job and hold the connection until it finishes or the wait ceiling is reached.
Find a job again
fal gives you no way to list your own jobs, so a lost request_id is a lost job and a lost charge. GET /v1/jobs is ours: this account's jobs, newest first, filterable by model, by category and by status. That filter takes the stored vocabulary (queued, running, succeeded, failed, canceled) rather than the three wire statuses, because show me my failed jobs is the question the route exists to answer and COMPLETED cannot express it. It is keyset-paged: send the previous page's next_cursor as cursor, up to 200 rows at a time. A row carries the status, the ids and what it cost, never the payload. Fifty jobs of upstream JSON is a megabyte of response to draw a table, so results stay behind their own URL.
Video, on OpenRouter's paths
The same queue answers a second set of paths for video specifically. POST /v1/videos submits (it is the same handler as POST /v1/videos/generations, which is OpenAI's spelling of it), and GET /v1/videos/{job_id} polls, returning the status while the job runs and the result once it finishes. GET /v1/videos/{job_id}/content streams the finished bytes. Use whichever set your client already speaks; the job, the id and the bill are the same either way.
POST/v1/videos/generationsAvailable
Submit a video generation and get a job id back. Answers 202: the work runs for minutes.
GET/v1/videos/{job_id}Available
Poll a video job: its status while it runs, its result once it finishes.
GET/v1/videos/{job_id}/contentAvailable
Stream the finished video's bytes.
GET/v1/jobsAvailable
List this account's jobs, newest first, keyset-paged.
{
"data": [
{
"request_id": "job_ab12cd34",
"model": "fal/veo3",
"category": "video",
"status": "COMPLETED",
"outcome": "succeeded",
"queue_position": null,
"gateway_request_id": "req_7c41d9be",
"cost": "0.4200",
"error_code": null,
"created_at": "2026-08-23T11:02:41Z",
"started_at": "2026-08-23T11:02:46Z",
"completed_at": "2026-08-23T11:04:00Z"
}
],
"next_cursor": null
}
What a job costs
Money is reserved at submit and settled at completion, the same sequence a synchronous call goes through. The hold is a ceiling computed from what you asked for (n at the model's per-image rate, or the seconds of video), and it is taken before any upstream call is made, so a submit can be refused with 402 insufficient_credits before any work starts. GET /v1/credits reports the total of open reservations under held, which is why an account with jobs in flight has a balance larger than what it can spend.
- A job that succeeds settles at the real cost, and the rest of the reservation goes back.
- A job that fails releases the reservation. Nothing is charged.
- A cancelled job releases the reservation. Nothing is charged, whether it had started or not.
- A completed job also writes a usage-log row, so it sits beside synchronous calls in the history and GET /v1/generation?id=req_… answers for it.
Be called back instead
Pass webhook_url in the submit body, or fal's ?fal_webhook= as a query parameter (they mean the same thing), and the terminal state is POSTed to that URL, for failures and cancellations as well as successes. There is no delivery for a transition into IN_PROGRESS; use the stream for progress. Every delivery is signed, and a signature you do not check is a callback anyone can forge: the webhooks page has the payload, the three headers and a verification routine in Node and Python.
How long a job stays readable
A finished job's input and output stay readable for a retention window the operator sets, thirty days by default, after which the row is swept. That window is not the billing record: the usage-log row the job wrote outlives it, so what a sweep removes is the prompt and the result, never the charge or the usage history. Store anything you need to keep. Payload carries URLs that expire, not files we hold for you.