Skip to main content
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.
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, 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.
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 means by “bearer token”).
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:
Save as quickstart.py and run it with python quickstart.py:

TypeScript

Requires Node 18+ (for built-in fetch, AbortSignal.timeout and crypto.randomUUID) and tsx to run TypeScript directly:
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:

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

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.

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.
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.
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 }).

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