Skip to content
docs/queueCore API

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

METHODPATHWHAT IT DOES
POST/v1/queue/{model}Submit a job. Returns immediately.
GET/v1/queue/requests/{job_id}/statusPoll: status, outcome, queue position, timings, optional logs.
GET/v1/queue/{model}/requests/{job_id}/statusfal'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}/cancelCancel, and release the reservation.
GET/v1/queue/requests/{job_id}/streamServer-sent status transitions, instead of polling.
POST/v1/run/{model}Submit and wait, for callers that would rather block than poll.
GET/v1/jobsList 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

cURL
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.

AUTHENTICATION
Bearer token, Authorization header
REQUIRED SCOPE
api-key

What a submit answers

JSON200 OK
{
  "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.

AUTHENTICATION
Bearer token, Authorization header
REQUIRED SCOPE
api-key
JSONGET /v1/queue/requests/job_ab12cd34/status?logs=1
{
  "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.

AUTHENTICATION
Bearer token, Authorization header
REQUIRED SCOPE
api-key
TEXTtext/event-stream
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.

AUTHENTICATION
Bearer token, Authorization header
REQUIRED SCOPE
api-key
JSONGET /v1/queue/requests/job_ab12cd34
{
  "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.

JSON503 Service Unavailable
{
  "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.

AUTHENTICATION
Bearer token, Authorization header
REQUIRED SCOPE
api-key
JSONPUT /v1/queue/requests/job_ab12cd34/cancel
{
  "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.

AUTHENTICATION
Bearer token, Authorization header
REQUIRED SCOPE
api-key

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.

AUTHENTICATION
Bearer token, Authorization header
REQUIRED SCOPE
api-key

GET/v1/videos/{job_id}Available

Poll a video job: its status while it runs, its result once it finishes.

AUTHENTICATION
Bearer token, Authorization header
REQUIRED SCOPE
api-key

GET/v1/videos/{job_id}/contentAvailable

Stream the finished video's bytes.

AUTHENTICATION
Bearer token, Authorization header
REQUIRED SCOPE
api-key

GET/v1/jobsAvailable

List this account's jobs, newest first, keyset-paged.

AUTHENTICATION
Bearer token, Authorization header
REQUIRED SCOPE
api-key
JSONGET /v1/jobs?status=succeeded&limit=1
{
  "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.