> ## Documentation Index
> Fetch the complete documentation index at: https://dripart-chore-sync-comfy-api-v2-spec.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Comfy Router quickstart

> From nothing to a generated image in about five minutes, in Python and TypeScript, against the Comfy Router.

<Note>
  **Comfy Router is not generally available yet.** The routes below —
  `POST /v2/models/{provider}/{model}` and its catalog and schema siblings — are
  not serving requests yet: an authenticated call answers `404` today. This page
  documents the contract they will serve, and is published ahead of that rollout so
  the integration is ready to write against. It is not a description of behaviour
  you can exercise right now.
</Note>

Comfy Router runs partner models behind one host, one credential and one route shape. This page is the shortest complete path to a generated image: install a client, set a key, send one request, read the result — and see what the first failure looks like before you hit it.

Base URL: `https://api.comfy.org`. The route is `POST /v2/models/{provider}/{model}`, the request body is the model's own native JSON input, and a `200` carries the model's own native JSON output. Router does not wrap either, so a call you already have written against the partner's API becomes a Router call by changing the host.

## Why this page uses `bfl/flux-2-pro`

`bfl/flux-2-pro` returns in about 3.1s at p50, which is the fastest measured path on the Router and is what makes a five-minute first result realistic — a slower model would spend that budget waiting rather than reading.

It is a convenience, not a requirement. Every other model on the Router is called exactly the same way: same route, same credential header, same error buckets, same `X-Comfy-Request-Id`. Only the model ID, the fields inside the request body, and the shape of the result you read back change. Gemini, for instance, clears comfortably at 72.8s p95 — Router holds the connection for the whole generation rather than returning a job handle to poll. There is no edge ceiling cutting a long call short, but Router does bound the call itself: its own server deadline (10 minutes by default) is the longest it will hold a connection, after which it answers `504` / `deadline_exceeded`. That bound is on the connection, not on the charge — a generation the provider completes is billed whether or not you received it, which is what the `Idempotency-Key` below is for. Swap the ID and read that model's fields from its own schema (below).

## Get a key

Router authenticates with a Comfy API key. Create one at [platform.comfy.org/profile/api-keys](https://platform.comfy.org/profile/api-keys), then put it in the environment — both samples below read `COMFY_API_KEY` and neither takes a key as a literal, so a copy-pasted snippet cannot carry your credential into a commit.

```bash theme={null}
export COMFY_API_KEY="comfyui-..."
```

<Note>
  A `comfyui-` key is accepted in either the **`X-API-Key`** header or
  **`Authorization: Bearer`** — the `comfyui-` prefix, not the header, tells the
  service it is an API key, so both forms are looked up identically. The examples
  below use `X-API-Key`; `Authorization: Bearer $COMFY_API_KEY` is equivalent.
  If both headers are sent, a key in `X-API-Key` takes precedence. A value in
  `Authorization: Bearer` WITHOUT the `comfyui-` prefix is treated as a
  Cloud/Firebase **JWT** (that is what the generated
  [API reference](/comfy-router-reference) means by "bearer token").
</Note>

Keys are per workspace and carry that workspace's model entitlements and credit balance. A request with no usable credential comes back `401` with `X-Comfy-Error-Type: unauthorized`; one whose workspace cannot run the model comes back `403` / `forbidden`.

## Python

Requires Python 3.9+ and `httpx`:

```bash theme={null}
pip install httpx
```

Save as `quickstart.py` and run it with `python quickstart.py`:

```python theme={null}
import os
import uuid

import httpx

BASE_URL = os.environ.get("COMFY_ROUTER_BASE_URL", "https://api.comfy.org")
MODEL = "bfl/flux-2-pro"

# Give the client headroom ABOVE Router's own server deadline (10 minutes by
# default) so a call that reaches the server bound comes back as a typed 504
# with a request id rather than as an opaque client abort. The deadline bounds
# how long Router holds the connection, not whether the call is billed: if the
# provider completed the generation, it is billed either way.
READ_TIMEOUT_SECONDS = 660.0


class RouterError(Exception):
    """A Comfy Router failure, typed by its X-Comfy-Error-Type bucket."""

    def __init__(self, response: httpx.Response) -> None:
        self.error_type = response.headers.get("X-Comfy-Error-Type", "internal_error")
        self.request_id = response.headers.get("X-Comfy-Request-Id")
        self.status_code = response.status_code
        # Seconds to wait before re-sending the SAME Idempotency-Key. Router
        # sends it on the two answers that mean "the work exists, ask again" -
        # a deadline_exceeded 504 over a generation still running, and the 409
        # that refuses a key whose original call is still in flight.
        raw_retry_after = response.headers.get("Retry-After")
        self.retry_after = int(raw_retry_after) if (raw_retry_after or "").isdigit() else None
        # Parse defensively: an error can arrive as an HTML 502 from a load
        # balancer, a plain-text 429, an empty body or a truncated JSON one. The
        # status, the bucket and the request id above are the parts worth
        # keeping, so a body that will not parse must not replace this exception
        # with a JSONDecodeError and lose them.
        body = None
        if response.headers.get("content-type", "").startswith("application/json"):
            try:
                body = response.json()
            except ValueError:
                body = None
        detail = body.get("detail") if isinstance(body, dict) else None
        # A 422 carries a detail[] array - one entry per rejected field, each
        # keeping its own `loc`, `msg` and `type`. Every other bucket carries a
        # plain `detail` string.
        self.errors = detail if isinstance(detail, list) else []
        self.detail = detail if isinstance(detail, str) else f"HTTP {response.status_code}"
        super().__init__(self.detail)


def run(model: str, arguments: dict, idempotency_key: str) -> dict:
    # Idempotency-Key makes a retry safe on a PAID call: Router replays the
    # original response for 24h instead of dispatching (and billing) the
    # provider a second time. Reuse the SAME key when retrying one logical
    # call; generate a new one for a new call.
    response = httpx.post(
        f"{BASE_URL}/v2/models/{model}",
        headers={
            "X-API-Key": os.environ["COMFY_API_KEY"],
            "Idempotency-Key": idempotency_key,
        },
        json=arguments,
        timeout=httpx.Timeout(READ_TIMEOUT_SECONDS, connect=10.0),
    )
    if response.is_error:
        raise RouterError(response)
    return response.json()


result = run(
    MODEL,
    {"prompt": "a red teapot on a windowsill, morning light"},
    idempotency_key=str(uuid.uuid4()),
)
# Router forwards each provider's native output unchanged, so this path is
# BFL's, not a Router envelope. Reading a different model means reading its own
# output shape.
print("image:", result["result"]["sample"])

# The first failure most callers hit: a field the model's input schema requires
# is missing, so Router rejects the request BEFORE any provider call - which is
# why a 422 is never billed.
try:
    run(MODEL, {"width": 1024}, idempotency_key=str(uuid.uuid4()))
except RouterError as exc:
    print(f"{exc.error_type} (HTTP {exc.status_code}), request id {exc.request_id}")
    for entry in exc.errors:
        print("  ", ".".join(str(p) for p in entry["loc"]), "->", entry["msg"])
```

```text theme={null}
image: https://.../out.jpeg
invalid_input (HTTP 422), request id 6f1c...
   body.prompt -> Field required
```

## TypeScript

Requires Node 18+ (for built-in `fetch`, `AbortSignal.timeout` and `crypto.randomUUID`) and `tsx` to run TypeScript directly:

```bash theme={null}
npm install --save-dev tsx
```

Save as `quickstart.mts` — the `.mts` extension is load-bearing, because the file uses top-level `await` and that needs an ES module — and run it with `npx tsx quickstart.mts`:

```typescript theme={null}
const BASE_URL = process.env.COMFY_ROUTER_BASE_URL ?? "https://api.comfy.org";
const MODEL = "bfl/flux-2-pro";

// Headroom ABOVE Router's own server deadline (10 minutes by default), so a
// call that reaches the server bound returns a typed 504 with a request id
// rather than aborting locally at the same moment. The deadline bounds how
// long Router holds the connection, not whether the call is billed: if the
// provider completed the generation, it is billed either way.
const CLIENT_TIMEOUT_MS = 660_000;

interface ValidationEntry {
  loc: (string | number)[];
  msg: string;
  type: string;
}

/** A Comfy Router failure, typed by its `X-Comfy-Error-Type` bucket. */
class RouterError extends Error {
  readonly errorType: string;
  readonly requestId: string | null;
  readonly status: number;
  /** Seconds to wait before re-sending the SAME `Idempotency-Key`. Router
   * sends it on the two answers that mean "the work exists, ask again" — a
   * `deadline_exceeded` `504` over a generation still running, and the `409`
   * that refuses a key whose original call is still in flight. */
  readonly retryAfter: number | null;
  /** A 422 carries a `detail[]` array — one entry per rejected field, each
   * keeping its own `loc`, `msg` and `type`. Every other bucket carries a
   * plain `detail` string. */
  readonly errors: ValidationEntry[];

  constructor(response: Response, body: unknown) {
    const detail =
      typeof body === "object" && body !== null
        ? (body as { detail?: unknown }).detail
        : undefined;
    super(typeof detail === "string" ? detail : `HTTP ${String(response.status)}`);
    this.name = "RouterError";
    this.errorType = response.headers.get("X-Comfy-Error-Type") ?? "internal_error";
    this.requestId = response.headers.get("X-Comfy-Request-Id");
    this.status = response.status;
    const retryAfter = Number(response.headers.get("Retry-After"));
    this.retryAfter = Number.isInteger(retryAfter) && retryAfter > 0 ? retryAfter : null;
    this.errors = Array.isArray(detail) ? (detail as ValidationEntry[]) : [];
  }
}

/** Read a body without letting a non-JSON error page mask the real failure. */
async function parseBody(response: Response): Promise<unknown> {
  const text = await response.text();
  try {
    return JSON.parse(text) as unknown;
  } catch {
    return undefined;
  }
}

async function run<T>(
  model: string,
  args: Record<string, unknown>,
  idempotencyKey: string,
): Promise<T> {
  // Idempotency-Key makes a retry safe on a PAID call: Router replays the
  // original response for 24h instead of dispatching (and billing) the provider
  // a second time. Reuse the SAME key when retrying one logical call.
  const response = await fetch(`${BASE_URL}/v2/models/${model}`, {
    method: "POST",
    headers: {
      "X-API-Key": process.env.COMFY_API_KEY ?? "",
      "Idempotency-Key": idempotencyKey,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(args),
    signal: AbortSignal.timeout(CLIENT_TIMEOUT_MS),
  });
  // Branch on `ok` FIRST: an HTML 502, a plain-text 429 or an empty body must
  // still surface the status, the bucket and the request id.
  const body = await parseBody(response);
  if (!response.ok) throw new RouterError(response, body);
  return body as T;
}

const result = await run<{ result: { sample: string } }>(
  MODEL,
  { prompt: "a red teapot on a windowsill, morning light" },
  crypto.randomUUID(),
);
// Router forwards each provider's native output unchanged, so this path is
// BFL's, not a Router envelope. Reading a different model means reading its own
// output shape.
console.log("image:", result.result.sample);

// The first failure most callers hit: a field the model's input schema requires
// is missing, so Router rejects the request BEFORE any provider call - which is
// why a 422 is never billed.
try {
  await run(MODEL, { width: 1024 }, crypto.randomUUID());
} catch (exc) {
  if (!(exc instanceof RouterError)) throw exc;
  console.log(`${exc.errorType} (HTTP ${String(exc.status)}), request id ${String(exc.requestId)}`);
  for (const entry of exc.errors) console.log("  ", entry.loc.join("."), "->", entry.msg);
}
```

```text theme={null}
image: https://.../out.jpeg
invalid_input (HTTP 422), request id 6f1c...
   body.prompt -> Field required
```

## Reading the `422`

The `422` is the one error worth understanding before your first real call, because it is the one you cause. It means Router checked your body against the model's own input schema and rejected it — a required field missing, a value outside a bound, an image too small. That check runs BEFORE any provider call, so a `422` costs nothing: no partner spend, no billing question to answer afterwards. It is not the same as a `400`, which is a request-level failure (a malformed cursor, an unreadable envelope) rather than a per-field one.

Its body is the FastAPI `detail[]` shape: an array with one entry per offending field, each keeping its own `loc` (the path to the field), `msg`, `type` (the specific, provider-level reason — `missing`, `value_error`, `image_too_small`) and, where the reason carries a bound, `ctx`. That per-field granularity is why the samples above keep the array as data instead of flattening it into the exception message.

<Note>
  A model whose input schema has not been authored yet resolves to a documented
  permissive fallback that admits any JSON object, so it will forward a body
  rather than answer `422`. The samples above show the shape you handle once a
  schema exists; treat the `422` block as the error path, not as a guaranteed
  response to that particular body.
</Note>

That body carries no `error_type` field of its own, so on a `422` the `X-Comfy-Error-Type` header is the *only* machine-readable bucket. Both samples read the bucket from the header first for exactly that reason, which is also what makes one error class enough to cover every failure Router can return.

`X-Comfy-Request-Id` is on every response — success, `4xx` and `5xx` alike — and is the id to quote in a support request. Both samples attach it to the exception rather than making you re-run with header logging on to find it.

## Retrying safely with your own key

Both samples above send an `Idempotency-Key`. It is worth a section of its own, because the header only does its job if you handle the key correctly — and the step that makes the difference happens before the request is even sent.

**Bring your own key — Router never gives it back.** Router does not mint one for you, and **no Router response carries the `Idempotency-Key`** — not the `200`, not the `504`, not any error body. It is yours to mint, so a caller who generated one inline and did not store it has no handle on a generation they may already have been billed for. Generate a fresh key per *logical call* — a UUID is the intended shape — and reuse that same key for every retry *of that call*. A new key per attempt buys you nothing; a key reused across two genuinely different calls is a `409`, because the same key with a different request — a different body, but also a different model path, query string or method — is a conflict rather than a silent overwrite. That includes correcting a `422`: the validation failure is itself recorded against the key, so the fixed body under the old key is a `409` — send it under a new key.

**Persist it before you send.** Write the key somewhere that outlives the request — the row you are generating for, your job record, your queue message — *before* the `POST` goes out, not after the response comes back. A key that only ever existed in the memory of the process that crashed cannot be resent, and the retry that would have been answered from Router's record becomes a fresh, separately charged run instead. This is the one step that is easy to skip and expensive to skip.

**Retry with it.** Router holds the key for **24 hours from the first request**. On a retry, it answers rather than re-runs whenever it still holds state for the key:

| What you get back                                                                                                              | What it means                                                                                                                                                                 | What to do                                                                 |
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `200` with `Idempotent-Replayed: true`                                                                                         | Router replayed the original response. Not billed again.                                                                                                                      | Use it — it is the original result.                                        |
| `409` / `concurrency_limit_exceeded`                                                                                           | The original call is still running.                                                                                                                                           | Wait `Retry-After` seconds, re-send **the same key**.                      |
| `409` / `invalid_input`                                                                                                        | The key cannot serve this request: a different request (body, model path, query or method) under the same key, or the original completed and its response cannot be replayed. | Use a **new** key. Do not re-send this one.                                |
| `504` / `deadline_exceeded` with `Retry-After`                                                                                 | Router stopped holding the connection but still holds a handle to a generation the provider is running.                                                                       | Wait `Retry-After` seconds, re-send **the same key** to collect it.        |
| Nothing — no response was ever committed to you, or the call ended in a `5xx` other than that `504`, or in a `408`/`425`/`429` | None of those charged you, so Router released the key.                                                                                                                        | Re-send if you want a **fresh run** — it is a fresh charge, not a collect. |

Continuing the Python sample above — a file stands in for whatever durable store
you already have; the ORDERING is the part that matters, not the mechanism. Persist
the whole request next to the key, not the key alone: a retry has to re-send the
*same* model and arguments, and one rebuilt from memory after a restart that differs
by so much as a whitespace is a `409`, while one sent under a fresh key is a second
billed generation.

```python theme={null}
import json
import uuid

# Persist BEFORE the request, so a crash between here and the response still
# leaves a key — and the exact request it belongs to — you can retry with.
request = {
    "model": MODEL,
    "arguments": {"prompt": "a red teapot on a windowsill, morning light"},
    "idempotency_key": str(uuid.uuid4()),
}
with open("pending-call.json", "w") as f:
    json.dump(request, f)

result = run(request["model"], request["arguments"], idempotency_key=request["idempotency_key"])
```

## If the call times out or you lose the connection

This is the one failure you do not cause and cannot avoid by writing a better request: Router holds the connection for the whole generation, and a long one can outlast the connection. Past Router's own server deadline (10 minutes by default) you get `504` with `X-Comfy-Error-Type: deadline_exceeded`; a dropped socket, a redeployed worker or a closed laptop lid gets you nothing at all. Either way the *generation* may still be running at the provider, and **a generation that completes is billed whether or not you received it**. So the question is never "was I charged" — it is "can I still collect what I paid for".

The key you persisted above is the answer. Re-send the SAME request — same model path, same body — under the SAME key, and the table above says what each answer means: a `504` or a `409` carrying `Retry-After` is "ask again on that interval", a `200` with `Idempotent-Replayed: true` is the result you were owed, and anything without `Retry-After` is Router's final answer on that key.

### Collecting after a lost response

Both snippets re-send the key the failed call used and honour `Retry-After` until the generation finishes. Neither mints a new key anywhere in the loop — that is the entire point.

```python theme={null}
import time


def collect(model: str, arguments: dict, idempotency_key: str, attempts: int = 30) -> dict:
    """Re-send one call under its ORIGINAL key until Router has an answer."""
    for _ in range(attempts):
        try:
            response = httpx.post(
                f"{BASE_URL}/v2/models/{model}",
                headers={
                    "X-API-Key": os.environ["COMFY_API_KEY"],
                    "Idempotency-Key": idempotency_key,
                },
                json=arguments,
                timeout=httpx.Timeout(READ_TIMEOUT_SECONDS, connect=10.0),
            )
            if response.is_error:
                raise RouterError(response)
            if response.headers.get("Idempotent-Replayed") == "true":
                print("collected the original generation; not charged again")
            return response.json()
        except RouterError as exc:
            # deadline_exceeded means the generation is still running;
            # concurrency_limit_exceeded on a 409 means another attempt is
            # already collecting it. Both say "ask again", and both name when.
            # Anything without a Retry-After is Router's final answer on this
            # key - including the 409 that says the key is spent for good.
            if exc.retry_after is None:
                raise
            time.sleep(exc.retry_after)
        except httpx.HTTPError:
            # The flaky connection that lost the response in the first place can
            # just as easily lose a collect attempt. The key is unchanged, so
            # asking again is still safe.
            time.sleep(2)
    raise TimeoutError(f"gave up collecting {idempotency_key} after {attempts} attempts")


# Mint the key FIRST, so it survives the call that fails.
arguments = {"prompt": "a red teapot on a windowsill, morning light"}
key = str(uuid.uuid4())
try:
    result = run(MODEL, arguments, idempotency_key=key)
except RouterError as exc:
    if exc.error_type != "deadline_exceeded":
        raise
    result = collect(MODEL, arguments, idempotency_key=key)
except httpx.HTTPError:
    # A dropped connection never reached a response, so there is no bucket to
    # branch on - but the key is still good and the generation may still be
    # running, which is exactly what collect() is for.
    result = collect(MODEL, arguments, idempotency_key=key)
print("image:", result["result"]["sample"])
```

```typescript theme={null}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/** Re-send one call under its ORIGINAL key until Router has an answer. */
async function collect<T>(
  model: string,
  args: Record<string, unknown>,
  idempotencyKey: string,
  attempts = 30,
): Promise<T> {
  for (let i = 0; i < attempts; i++) {
    let response: Response;
    try {
      response = await fetch(`${BASE_URL}/v2/models/${model}`, {
        method: "POST",
        headers: {
          "X-API-Key": process.env.COMFY_API_KEY ?? "",
          "Idempotency-Key": idempotencyKey,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(args),
        signal: AbortSignal.timeout(CLIENT_TIMEOUT_MS),
      });
    } catch (exc) {
      // The flaky connection that lost the response in the first place can just
      // as easily lose a collect attempt. The key is unchanged, so asking again
      // is still safe - but a caller's own abort is theirs and stops the loop.
      if (!(exc instanceof TypeError || exc instanceof DOMException)) throw exc;
      await sleep(2000);
      continue;
    }
    const body = await parseBody(response);
    if (response.ok) {
      if (response.headers.get("Idempotent-Replayed") === "true") {
        console.log("collected the original generation; not charged again");
      }
      return body as T;
    }
    // `deadline_exceeded` means the generation is still running;
    // `concurrency_limit_exceeded` on a 409 means another attempt is already
    // collecting it. Both say "ask again", and both name when. Anything with no
    // `Retry-After` is Router's final answer on this key.
    const error = new RouterError(response, body);
    if (error.retryAfter === null) throw error;
    await sleep(error.retryAfter * 1000);
  }
  throw new Error(`gave up collecting ${idempotencyKey} after ${String(attempts)} attempts`);
}

// Mint the key FIRST, so it survives the call that fails.
const args = { prompt: "a red teapot on a windowsill, morning light" };
const key = crypto.randomUUID();
let image: { result: { sample: string } };
try {
  image = await run<{ result: { sample: string } }>(MODEL, args, key);
} catch (exc) {
  const lostTheResponse =
    exc instanceof RouterError
      ? exc.errorType === "deadline_exceeded"
      // A dropped connection or a local abort never reached a response, so it
      // arrives as a plain fetch failure with no bucket to branch on - but the
      // key is still good and the generation may still be running, which is
      // what collect() is for. Anything else is a bug in this program rather
      // than a lost result, so it propagates.
      : exc instanceof TypeError || exc instanceof DOMException;
  if (!lostTheResponse) throw exc;
  image = await collect<{ result: { sample: string } }>(MODEL, args, key);
}
console.log("image:", image.result.sample);
```

### Replaying from a different process

Nothing above requires the retry to happen in the same process, or even on the same day — the key is the only state that has to survive, and Router keeps its side for 24 hours. If the process that made the call might not be the one that reads the result, derive the key from something you already persist (an order id, a job row's primary key) instead of a fresh UUID, store it alongside that record *before* you call, and hand it to `collect()` from wherever the recovery runs.

```python theme={null}
key = f"order-{order_id}-render"   # deterministic, stored with the order
```

The key must still be unique per logical call — a second render of the same order needs a distinct key, or Router hands back the first render's result rather than rendering again (or refuses the resend `409` if the request changed at all). Keys are scoped to the authenticated caller, so you only have to be unique within your own workspace, and the value is any non-empty string up to 255 characters.

<Note>
  **The official SDKs do the minting for you.** Both send an `Idempotency-Key` on
  every `models.run()` call, mint a fresh one per call, and reuse that one key
  across their own internal retries — including retrying a `deadline_exceeded`
  `504` under it, so a single `run()` can ride the collect loop through the server
  deadline to the finished generation. Neither one starts a second billed
  generation on a retry it made itself.

  Getting the key back **after** `run()` gives up differs between them today. In
  Python, every exception carries the key it sent, so the recovery idiom is
  `client.models.run(model, arguments, idempotency_key=exc.idempotency_key)` —
  check the attribute is not `None` before passing it back, because the parameter
  treats `None` as "mint a new one", which starts a second billed generation
  instead of collecting the first. In TypeScript the errors do not carry it yet,
  so keep the key yourself exactly as the samples above do and pass it back as
  `models.run(model, input, { idempotencyKey })`.
</Note>

## Find a model

`bfl/flux-2-pro` is one ID; the catalog is the rest. `GET /v2/models` lists every model Router can run, one page at a time, and each entry is exactly what you need to call it: the `id` you put in the path, its `provider` and `model` segments carried separately, and a `billing` block you can branch on before you spend anything.

```bash theme={null}
curl -H "X-API-Key: $COMFY_API_KEY" \
  "https://api.comfy.org/v2/models?limit=50"
```

```json theme={null}
{
  "data": [
    { "id": "bfl/flux-2-pro", "provider": "bfl", "model": "flux-2-pro", "billing": { "charges_on_policy_rejection": "no" } }
  ],
  "has_more": true,
  "next_cursor": "q7Fm2xTn9pLd4RsV",
  "limit": 50
}
```

Walk it with the cursor, not with an offset: pass `next_cursor` back as `?cursor=` and stop when `has_more` is `false` — not when a page comes back short. The cursor is opaque and only ever round-tripped; a cursor Router does not accept is a `400` / `invalid_input`, never a silent restart at page one. A cursor is a position in the catalog's sorted order, so it stays valid across a deploy that adds or removes models — a model added behind your position is simply not visited on that walk. A `503` / `service_unavailable` on the list means Router could not answer yet (a pod still loading its release state); retry it, do not read it as an empty catalog. `limit` on the response is the page size actually served — a request above the cap is clamped rather than rejected, so paginate with the number you got back. A model that is deployed but not yet released is simply absent from every page.

## Where the model's fields come from

`prompt` is the only field `bfl/flux-2-pro` requires; `width`, `height`, `seed` and `output_format` are the ones you will reach for next. Rather than reproducing a field list that can drift, read the model's schema live:

```bash theme={null}
curl -H "X-API-Key: $COMFY_API_KEY" \
  https://api.comfy.org/v2/models/bfl/flux-2-pro/openapi.json
```

That is the same document the server validates your call against, served as a standalone OpenAPI document, so what is published and what is enforced cannot disagree. Take any `id` from the catalog above, append `/openapi.json` to its invocation path, and generate against what comes back.

## Next

* [Comfy Router API reference](/comfy-router-reference) — every endpoint, every parameter, and all fifteen error buckets.
* [Comfy Router limitations](/comfy-router-limitations) — what Router does not do today, and what to use instead.
