Skip to content
docs/migrate-falGuides

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

FALHEREWHAT 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}/statusGET /v1/queue/{model}/requests/{job_id}/statusSame handler as the short form below. ?logs=1 is gated exactly as fal gates it.
-GET /v1/queue/requests/{job_id}/statusShort 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}/cancelPUT /v1/queue/requests/{job_id}/cancelShort form only. There is no model-prefixed cancel. Follow cancel_url.
GET https://queue.fal.run/{model}/requests/{id}/status/streamGET /v1/queue/requests/{job_id}/streamSSE 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/jobsOurs. 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

JSONPOST /v1/queue/fal/veo3
{
  "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

JSONGET /v1/queue/requests/job_ab12cd34/status?logs=1
{
  "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

JAVASCRIPT
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

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

JSONWhat is POSTed to the callback on a terminal state
{
  "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.upload equivalent. Inputs that take a file take a publicly reachable https URL you host.
  • No realtime transport. There is no WebSocket surface. GET /v1/queue/requests/{job_id}/stream streams 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

  1. 01
    Move input up a level

    Whatever sat under input in the client call becomes the request body. Nothing inside it needs renaming.

  2. 02
    Resolve the model id

    Find the slug in GET /v1/models and put it in the path. Slugs contain slashes and the path segment is a wildcard, so fal/veo3 goes in whole: /v1/queue/fal/veo3.

  3. 03
    Swap the credential

    Authorization: Key <id>:<secret> becomes Authorization: Bearer sk_live_….

  4. 04
    Follow the URLs you were handed

    Poll status_url, fetch response_url, cancel with cancel_url. They are absolute, and using them is what keeps the client working when a path shape changes.

  5. 05
    Branch on outcome, not on status

    status === "COMPLETED" ends the loop. outcome === "succeeded" is what says the output is usable.