Migrate from fal.ai
The queue routes, in fal's path shapes, with one deliberate difference.
Submit with POST /v1/queue/{model}, poll the status route, fetch the result, cancel with PUT, and be called back with ?fal_webhook=. The paths keep fal's shape and the submit response keeps fal's field names: request_id, status_url, response_url, cancel_url. The one deliberate difference is failure: status carries fal's three values only, so a job that failed still reads COMPLETED, and the additive outcome field is what tells the three apart.
Route map
| FAL | HERE | WHAT CHANGES |
|---|---|---|
| POST https://queue.fal.run/{model} | POST /v1/queue/{model} | The model's own input is the body, unchanged. Response is fal's submit shape. |
| GET https://queue.fal.run/{model}/requests/{id}/status | GET /v1/queue/{model}/requests/{job_id}/status | Same handler as the short form below. ?logs=1 is gated exactly as fal gates it. |
| - | GET /v1/queue/requests/{job_id}/status | Short form. The model segment is optional on status and result. |
| GET https://queue.fal.run/{model}/requests/{id} | GET /v1/queue/{model}/requests/{job_id} | The result. GET /v1/queue/requests/{job_id} is the same handler. |
| PUT https://queue.fal.run/{model}/requests/{id}/cancel | PUT /v1/queue/requests/{job_id}/cancel | Short form only. There is no model-prefixed cancel. Follow cancel_url. |
| GET https://queue.fal.run/{model}/requests/{id}/status/stream | GET /v1/queue/requests/{job_id}/stream | SSE of status transitions. Different path, and short form only. |
| POST https://fal.run/{model} | POST /v1/run/{model} | Synchronous. Past the wait ceiling it hands back the job to poll rather than failing. |
| ?fal_webhook=<url> | ?fal_webhook=<url> | Honoured. webhook_url in the body means the same thing and survives a callback URL with its own query string. |
| - | GET /v1/jobs | Ours. fal has no job list; this is where a lost job id is found. |
The body is the input, at the top level
fal's HTTP API takes a model's own parameters at the top level of the POST body (prompt, image_url, duration, num_inference_steps, whatever that model accepts), and so does this one. Unknown keys are forwarded upstream rather than refused. The fal-client SDKs wrap that object under an input key of their own; porting a client call means taking what was inside input and making it the body.
Fields the gateway reads first
prompt, n, size and duration are read before the body is forwarded, because the hold taken at submit is a ceiling computed from how many images or how many seconds you asked for. webhook_url is ours and is stripped on the way out, so an upstream never receives it. Everything else passes through.
Authentication
fal sends Authorization: Key <id>:<secret>. Here it is Authorization: Bearer sk_live_…, the same key as every other route on the account. There is no separate queue credential and no per-model key.
Model ids
fal ids are namespaced by fal: fal-ai/veo3, fal-ai/flux/dev. Slugs here come from GET /v1/models and are not a mechanical rewrite of them: some line up, some do not. Resolve every id you submit today before cutting over. A slug that does not exist answers 409 model_unavailable at submit, before any money is held.
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"
}
What a poll answers
{
"request_id": "job_ab12cd34",
"status": "COMPLETED",
"outcome": "succeeded",
"queue_position": 0,
"logs": [
{ "message": "sampling frame 120/120", "level": "INFO", "timestamp": "2026-08-23T11:02:47Z" }
],
"metrics": { "inference_time": 74210 },
"error": null
}
Before
import { fal } from "@fal-ai/client";
fal.config({ credentials: process.env.FAL_KEY });
const result = await fal.subscribe("fal-ai/veo3", {
input: {
prompt: "drone shot over a harbour at dawn",
duration: 5,
},
logs: true,
});
console.log(result.data.video.url);
After
const AUTH = { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` };
const submit = await fetch("https://api.routehook.ai/v1/queue/fal/veo3", {
method: "POST",
headers: { ...AUTH, "Content-Type": "application/json" },
body: JSON.stringify({
prompt: "drone shot over a harbour at dawn",
duration: 5,
}),
});
const job = await submit.json();
// Three statuses only, so this loop terminates on failure as well.
let state;
do {
await new Promise((resolve) => setTimeout(resolve, 1000));
state = await fetch(job.status_url, { headers: AUTH }).then((r) => r.json());
} while (state.status !== "COMPLETED");
if (state.outcome !== "succeeded") {
throw new Error(`${job.request_id} ${state.outcome}`);
}
const result = await fetch(job.response_url, { headers: AUTH }).then((r) =>
r.json(),
);
console.log(result.output.video.url);
fal-client cannot be repointed
Neither SDK takes a base URL. fal.config() accepts credentials and a proxyUrl, and the proxy has to be a route on your own origin that you write, so keeping the client means writing a forwarder that rewrites the path onto /v1/queue/{model}, swaps the Key credential for a bearer token, and unwraps input into the body. Four fetch calls, as above, is less code than the proxy and one fewer hop to debug.
Webhooks
?fal_webhook=<url> is honoured and means exactly what it means at fal. webhook_url in the body means the same thing and is the better field when your callback URL carries a query string of its own. Nesting one URL inside another's query parameter is the encoding most often got wrong. The URL must be https and must not resolve into a private range.
{
"request_id": "job_ab12cd34",
"gateway_request_id": "req_7c41d9be",
"status": "COMPLETED",
"outcome": "succeeded",
"payload": {
"video": { "url": "https://cdn.routehook.ai/vid/ab12cd34.mp4" }
},
"error": null
}
Deliveries are signed. X-Routehook-Webhook-Signature carries v1=<hex hmac-sha256> over <id>.<timestamp>.<body>, keyed on the account webhook secret, alongside X-Routehook-Webhook-Id and X-Routehook-Webhook-Timestamp. Verify before you act on a payload, /docs/webhooks has the check.
Idempotency
Send an Idempotency-Key header on a submit and a repeat returns the existing job instead of opening (and billing) a second one. fal has no equivalent, so this is the one place worth adding a line rather than removing one: a retry after a timeout is the case that quietly pays twice.
What is not here
- No file storage. There is no
fal.storage.uploadequivalent. Inputs that take a file take a publicly reachablehttpsURL you host. - No realtime transport. There is no WebSocket surface.
GET /v1/queue/requests/{job_id}/streamstreams status transitions over SSE, not tokens or frames. - No
fal.stream. Partial output is not streamed off the queue; poll or stream status, then fetch the result once. - No client-side proxy handler. fal ships route handlers for Next.js and friends. Here the key stays on your server and your server calls the API. See /docs/authentication.
Porting a call
- 01Move input up a level
Whatever sat under
inputin the client call becomes the request body. Nothing inside it needs renaming. - 02Resolve the model id
Find the slug in
GET /v1/modelsand put it in the path. Slugs contain slashes and the path segment is a wildcard, sofal/veo3goes in whole:/v1/queue/fal/veo3. - 03Swap the credential
Authorization: Key <id>:<secret>becomesAuthorization: Bearer sk_live_…. - 04Follow the URLs you were handed
Poll
status_url, fetchresponse_url, cancel withcancel_url. They are absolute, and using them is what keeps the client working when a path shape changes. - 05Branch on outcome, not on status
status === "COMPLETED"ends the loop.outcome === "succeeded"is what says the output is usable.