# Migrate from fal.ai

> The queue routes, in fal's path shapes, with one deliberate difference.

Section: Guides. Canonical page: https://routehook.ai/docs/migrate-fal

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.

> **Warning: COMPLETED does not mean succeeded**
>
> fal publishes `IN_QUEUE`, `IN_PROGRESS` and `COMPLETED` and nothing else, and a client polling until `COMPLETED` is the loop fal's own SDK produces. Inventing a `FAILED` status would make that loop spin for ever, so we did not: a job that failed or was cancelled reports `COMPLETED` too. **Read `outcome` (`succeeded`, `failed` or `canceled`) before you use the output.** Fetching the result of a failed job answers the standard error envelope with the failure's status code, exactly as fal does.

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

> **Warning: Cancel and stream have no model-prefixed form**
>
> Status and result answer on both path shapes. Cancel and stream answer only on `/v1/queue/requests/{job_id}/…`, so a client that rebuilds fal's `/{model}/requests/{id}/cancel` by hand will 404. Use the absolute `cancel_url` and `status_url` the submit response hands back. They are absolute for exactly this reason, and following them is the version of this code that never breaks.

## 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

**POST /v1/queue/google/veo-3.1**

```json
{
  "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

**GET /v1/queue/requests/job_ab12cd34/status?logs=1**

```json
{
  "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**

```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);
```

**Python**

```python
import fal_client

result = fal_client.subscribe(
    "fal-ai/veo3",
    arguments={
        "prompt": "drone shot over a harbour at dawn",
        "duration": 5,
    },
    with_logs=True,
)

print(result["video"]["url"])
```

## After

**JavaScript**

```javascript
const AUTH = { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` };

const submit = await fetch("https://api.routehook.ai/v1/queue/google/veo-3.1", {
  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);
```

**Python**

```python
import os, time, requests

AUTH = {"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}

job = requests.post(
    "https://api.routehook.ai/v1/queue/google/veo-3.1",
    headers=AUTH,
    json={
        "prompt": "drone shot over a harbour at dawn",
        "duration": 5,
    },
).json()

# Three statuses only, so this loop terminates on failure as well.
while True:
    state = requests.get(job["status_url"], headers=AUTH).json()
    if state["status"] == "COMPLETED":
        break
    time.sleep(1)

if state["outcome"] != "succeeded":
    raise RuntimeError(f"{job['request_id']} {state['outcome']}")

result = requests.get(job["response_url"], headers=AUTH).json()
print(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.

**What is POSTed to the callback on a terminal state**

```json
{
  "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. **Move input up a level**

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

2. **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 `google/veo-3.1` goes in whole: `/v1/queue/google/veo-3.1`.

3. **Swap the credential**

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

4. **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. **Branch on outcome, not on status**

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

## Next

- [Queue](/docs/queue): The full asynchronous surface, including /v1/run and /v1/jobs.
- [Webhooks](/docs/webhooks): The signature, the retry schedule and how to verify a delivery.
- [Pricing](/docs/pricing): How a hold becomes a charge, and what a cancel refunds.
