# Comfy API v2 — public specification.
#
# GENERATED ONE-WAY — DO NOT HAND-EDIT.
# Projected automatically from the canonical Comfy API v2 contract and
# synced by CI. Change the upstream contract, not this public copy.

openapi: 3.0.3
info:
  title: Comfy API v2
  version: 2.0.0
  description: "The official, versioned HTTP API for running ComfyUI workflows from\nexternal applications: upload inputs, submit a workflow, observe\nexecution, retrieve results.\n\nDesign principles:\n- **Poll-first.** Every capability is reachable via plain GET polling;\n  the SSE stream is a live enhancement, never the source of truth.\n- **Everything is resumable.** Submission is idempotent; job state and\n  outputs are retrievable by ID until `expires_at`.\n- **UUID identity, content-addressed dedup.** Assets are UUID-identified\n  records over blobs keyed by a server-computed blake3 hash. The hash is\n  nullable and may be computed lazily.\n- **Follow links, don't build URLs.** Responses embed follow-up URLs.\n\nAdditive changes only within v2; breaking changes require v3.\n"
servers:
- url: http://127.0.0.1:8189
  description: Self-hosted (comfy-api-proxy)
- url: https://cloud.comfy.org
  description: Comfy Cloud
- url: https://{deployment}.run.comfy.app
  description: Serverless deployment
  variables:
    deployment:
      description: DNS-safe deployment id (subdomain label). Staging uses {deployment}.stg.run.comfy.app.
      default: dep-1234abcd-56ef-7890-abcd-ef1234567890
security:
- bearerAuth: []
- {}
tags:
- name: assets
  description: UUID-identified records over content-addressed blobs.
- name: jobs
  description: One execution of a workflow — durable, pollable, cancelable.
paths:
  /api/v2/assets:
    post:
      operationId: postAssets
      tags:
      - assets
      summary: Upload an asset (single-call multipart)
      description: 'Single-call `multipart/form-data` upload. The platform streams the

        bytes through its trusted byte-path, dedups by the server-computed

        hash, and mints the asset record.


        The blake3 hash is always computed server-side from the received

        bytes — a client-declared `expected_hash` is verified, never trusted.

        The uploaded asset is referenceable immediately; any content scanning

        runs in the background and does not block the response.


        ~100 MB single-request expectation for v1; chunked/resumable large

        uploads are a deliberate open question.

        '
      x-streaming-upload: true
      x-idempotency-key: recommended
      parameters:
      - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              - content_type
              - file_path
              properties:
                file:
                  type: string
                  format: binary
                  description: The raw bytes.
                content_type:
                  type: string
                  example: image/png
                file_path:
                  type: string
                  description: Placement path / filename (global-namespace-root form, e.g. `photo.png` or `models/checkpoints/x.safetensors`).
                  example: photo.png
                expected_hash:
                  type: string
                  description: Optional client-computed blake3 (`blake3:<hex>`). Verified against the server-computed hash; mismatch is rejected with 409 `hash_mismatch` and no asset is minted.
                  example: blake3:9f8a1c0d...
                tags:
                  type: array
                  items:
                    type: string
                  description: Category tags (e.g. `input`).
                expires_in:
                  type: integer
                  minimum: 60
                  maximum: 604800
                  description: 'Optional retention override in seconds (60s–7d): the asset''s `expires_at` becomes now + `expires_in`, replacing the platform''s default retention. Implementations without configurable retention ignore it. The bounds apply to this override only — the platform default is operator-configured and may lie outside them.'
                  example: 86400
      responses:
        '201':
          description: New blob stored; asset minted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
        '200':
          description: Bytes deduped to an existing blob; asset minted over it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: '`hash_mismatch`.'
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '422':
          description: '`idempotency_key_reuse` or validation failure.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          $ref: '#/components/responses/UpstreamError'
  /api/v2/assets/from-hash:
    post:
      operationId: assetFromHash
      tags:
      - assets
      summary: Mint an asset over existing bytes (dedup fast-path)
      description: 'Zero-byte fast-path: mints a new asset UUID over a blob the platform

        already has, identified by its blake3 hash.


        Trust boundary: resolves only against blobs the platform itself

        ingested and hashed, and only those the calling account is authorized

        to use — the client hash is a lookup key, never an authority to

        register new content. A miss and "exists but not yours" are

        deliberately indistinguishable (`404` `blob_not_found`).

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - hash
              properties:
                hash:
                  type: string
                  example: blake3:9f8a1c0d...
                file_path:
                  type: string
                  example: photo.png
                tags:
                  type: array
                  items:
                    type: string
                expires_in:
                  type: integer
                  minimum: 60
                  maximum: 604800
                  description: 'Optional retention override in seconds (60s–7d): the asset''s `expires_at` becomes now + `expires_in`, replacing the platform''s default retention. Implementations without configurable retention ignore it. The bounds apply to this override only — the platform default is operator-configured and may lie outside them.'
                  example: 86400
      responses:
        '201':
          description: Asset minted over the existing blob.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
        '200':
          description: An identical reference already existed; returned as-is.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: '`blob_not_found` — no blob the caller may mint from.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          $ref: '#/components/responses/UpstreamError'
  /api/v2/assets/by-hash/{hash}:
    head:
      operationId: headAssetByHash
      tags:
      - assets
      summary: Existence check for a blob by blake3 hash
      description: '`200` if the calling account can mint from this blob, `404` otherwise. Same account-scoping as `from-hash`; lets a client decide between the dedup fast-path and a full upload before sending bytes.'
      parameters:
      - $ref: '#/components/parameters/BlakeHash'
      responses:
        '200':
          description: Blob present and mintable by the caller.
        '404':
          description: No blob the caller may mint from.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/UpstreamError'
  /api/v2/assets/{id}:
    get:
      operationId: getAsset
      tags:
      - assets
      summary: Asset metadata
      description: Returns the asset object with a fresh short-lived `url` for the content. Re-fetching always yields fresh URLs.
      parameters:
      - $ref: '#/components/parameters/AssetId'
      responses:
        '200':
          description: The asset.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/UpstreamError'
    delete:
      operationId: deleteAsset
      tags:
      - assets
      summary: Delete an asset record
      description: 'Deletes the asset RECORD. The underlying content-addressed blob is

        untouched while any other asset still references it (hash dedup means

        blobs are shared) — deleting an asset never destroys another asset''s

        bytes.


        A second delete of the same id returns `404`, indistinguishable from

        an id that never existed or belongs to another account.

        '
      parameters:
      - $ref: '#/components/parameters/AssetId'
      responses:
        '204':
          description: Record deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`asset_in_use` — the record cannot be deleted while the platform still depends on it. Each surface defines its own holds (for example: a job''s outputs reference the record, or a content-moderation workflow requires it to be preserved); the response body deliberately never says which hold applies.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          $ref: '#/components/responses/UpstreamError'
  /api/v2/assets/{id}/content:
    get:
      operationId: getAssetContent
      tags:
      - assets
      summary: Asset bytes
      description: 'Serves the bytes directly on surfaces where the platform stores blobs

        itself (self-hosted); on Cloud and serverless issues a `302` to a

        fresh signed URL. Range requests are supported for resumable

        downloads of large outputs.

        '
      parameters:
      - $ref: '#/components/parameters/AssetId'
      - name: Range
        in: header
        required: false
        schema:
          type: string
        description: Standard HTTP range, e.g. `bytes=0-1048575`.
      responses:
        '200':
          description: The full content.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '206':
          description: Partial content for a ranged request.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '302':
          description: Redirect to a fresh signed URL (Cloud / serverless).
          headers:
            Location:
              schema:
                type: string
                format: uri
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '416':
          description: Range not satisfiable.
        '500':
          $ref: '#/components/responses/UpstreamError'
  /api/v2/jobs:
    post:
      operationId: postJobs
      tags:
      - jobs
      summary: Submit a workflow for execution
      description: 'Accepts the API-format workflow graph verbatim. Validation is

        synchronous: graph structure, unknown node classes, and asset

        references (`core/ASSET` objects — every referenced `id` must exist

        and be owned by the caller). A `201` means the job is durably

        recorded and queued.


        UI-format workflow JSON (the export with `nodes`/`links`) is

        rejected with `workflow_format_ui`.


        `Idempotency-Key` is single-use (reject-on-duplicate, NOT

        record-and-replay): the first request to present a given key is

        processed normally; ANY later request presenting the same key — a

        retry, a concurrent duplicate, or a same-key request with a different

        body — is rejected `422` `idempotency_key_reuse` and is never

        re-executed. The key is claimed only for a request that actually

        reaches submission and is released if that submission definitively

        fails without creating a job (a validation error, or an upstream

        reject such as out-of-credits or queue-full), so a legitimate retry

        with the same key can proceed. If a submission''s outcome is unknown

        (an upstream timeout or 5xx where the job may or may not have been

        created), the key stays claimed and the retry is rejected: poll or

        list your jobs to find the possibly-created job rather than

        resubmitting. Keys expire after 24h. There is no response replay and

        no `Idempotency-Replayed` header.


        Reserved for post-MVP and rejected if present today: `webhook_url`,

        `inputs`.

        '
      x-idempotency-key: recommended
      parameters:
      - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - workflow
              properties:
                workflow:
                  type: object
                  description: API-format workflow graph, verbatim.
                  additionalProperties: true
                extra_data:
                  type: object
                  description: 'Per-prompt ComfyUI `extra_data`, same shape as Comfy Cloud and local ComfyUI. Closed object: only the enumerated keys are accepted, keeping the contract fully typed. Forwarded to the worker per-prompt and excluded from idempotency comparison. On a deployment it is dispatch-only and never stored; on Comfy Cloud it is persisted with the prompt, because the worker needs it, and redacted on every path that returns a workflow to a caller.


                    Send the one credential you hold: an API key as `api_key_comfy_org`, or the session token an interactively signed-in client has instead as `auth_token_comfy_org`. Sending both is accepted and both are forwarded, but it is not a supported combination and which one a node uses is not defined here. Note a session token is short-lived and is not re-minted for you, so one submitted long before it executes may expire in the queue.'
                  additionalProperties: false
                  properties:
                    api_key_comfy_org:
                      type: string
                      description: API key for partner (API) nodes.
                    auth_token_comfy_org:
                      type: string
                      description: Session bearer token for partner (API) nodes — the equivalent of `api_key_comfy_org` for a caller authenticated by session rather than by key.
      responses:
        '201':
          description: Job created and queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: '`insufficient_credits` (Cloud / serverless only).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: '`invalid_workflow` (with per-node details), `workflow_format_ui`, `missing_asset`, or `idempotency_key_reuse`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: '`queue_full` (bounded queue depth reached) or, on deployment-scoped surfaces, `deployment_not_ready` (deployment still provisioning/starting). Disambiguate by `error.code`; both mean back off and retry after `Retry-After`.'
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          $ref: '#/components/responses/UpstreamError'
  /api/v2/jobs/{id}:
    get:
      operationId: getJob
      tags:
      - jobs
      summary: Job status (the polling workhorse)
      description: 'Returns the full job object: current status, the latest progress

        snapshot, and every output committed so far (`outputs` populates

        incrementally while the job runs). This is the authoritative,

        resumable view of a job; everything on the SSE stream is derived

        from it.

        '
      parameters:
      - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: The job.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/UpstreamError'
  /api/v2/jobs/{id}/workflow:
    get:
      operationId: getJobWorkflow
      tags:
      - jobs
      summary: The workflow behind a job — authoring version if pinned, executed graph otherwise
      description: "Returns the workflow behind a job. The response's `format` field says\nwhich of two different shapes `workflow` is in:\n\n- `format: save` — the original authoring workflow exactly as saved\n  in the Comfy Cloud editor at the version the job ran, including\n  canvas layout and frontend-only nodes (e.g. Note nodes; Get/Set\n  nodes not yet expanded). Returned only when the job is pinned to a\n  specific workflow version — see the \"when you get which\" note\n  below.\n- `format: api` — the executed API-format prompt graph the job\n  actually ran: frontend-only constructs are gone and Get/Set nodes\n  are expanded. This is the same shape `POST /api/v2/jobs`'s\n  `workflow` request field takes, and never includes the\n  submission's `extra_data`, which can carry a live credential.\n\nAlways branch on `format`, never assume one or the other — which\nshape comes back depends on how the job was submitted, not on\nanything the caller controls per-request.\n\nA deliberate sub-resource, not a field on `GET /api/v2/jobs/{id}` —\nso the polling workhorse stays cheap and a caller pays for this only\nwhen it actually wants the workflow (for example, to recover what\nproduced a given output).\n\nTied to the job's own retention: this 404s under the same conditions\n`GET /api/v2/jobs/{id}` does (unknown, not-yours, or past its\nretention deadline) — there is no separate lifetime for the\nworkflow.\n\n**When you get which:** a job only carries a pinned workflow version\nwhen it was submitted with that association. Today that means jobs\nsubmitted from the Comfy Cloud frontend/editor. Jobs submitted\ndirectly through this v2 API (`POST /api/v2/jobs`) do not carry that\nassociation — v2 job submission has no version-linking fields yet —\nso they always get `format: api`. This is expected, not a bug: it\nwill change once v2 submission grows the same version pinning.\n\nA job pinned to a version also falls back to `format: api` if that\nversion, or the workflow it belongs to, is no longer readable by the\ncaller — for example the caller deleted the workflow since the job\nran. This is the same fallback as an unpinned job, and for the same\nreason: it is preferable to the alternative of erroring the whole\nrequest over data that is genuinely gone.\n"
      parameters:
      - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: The workflow graph.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobWorkflowResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/UpstreamError'
  /api/v2/jobs/{id}/logs:
    get:
      operationId: getJobLogs
      tags:
      - jobs
      summary: What the run printed
      description: 'Returns the job''s captured execution log. Fetched on demand: a log

        is a debugging artifact a caller wants occasionally, while

        `GET /api/v2/jobs/{id}` is polled to terminal on every run, so the

        log is a resource of its own rather than a field that would ride

        every one of those polls to be read at most once.


        Captured whenever the worker reports its own outcome, success and

        failure alike, since a job that succeeds while producing the wrong

        thing is exactly what a failure-only log cannot explain. A run the

        platform or the provider killed — out of memory, a crashed worker, a

        timeout, a job past its maximum runtime — never gets that far, so it

        reaches a terminal status carrying no log at all. That is a real gap

        and worth stating: the failures a caller most wants a log for are

        the ones least likely to have produced one.


        **`204` is the normal answer for a job with no log**, and the cases

        behind it are deliberately not distinguished: this surface does not

        capture logs at all, the job has not finished, the job predates log

        capture, the run was killed before the worker could report one,

        capture was attempted and failed, or the job ran on the public demo

        deployment, which captures and stores the log like every other

        serverless deployment but withholds it on read, because that surface

        takes callers with no credential and a job id would otherwise be the

        only thing between one anonymous caller and another''s run.


        Because a `204` never says which of those it is, do not branch on the

        reason — but do note that one of them resolves itself. A job that has

        not finished may have a log once it does, so a caller that wants one

        reads again after a terminal status. A `204` on a job already in a

        terminal state is final, and so is a missing `urls.logs`; both mean

        stop asking.


        **Only jobs run on the serverless platform** (a

        `{deployment}.run.comfy.app` host) have one today. An implementation

        that captures no logs must still serve this operation, answering

        `204` for every job it can read, so that the two answers stay

        distinct — Comfy Cloud does. A self-hosted deployment on a build

        predating this operation has not implemented it yet and will answer

        a routing `404` instead, which is the case `job.urls.logs` exists to

        keep a client out of: its absence says the surface has no logs at

        all, without a request.


        Tied to the job''s own retention: this `404`s under the same

        conditions `GET /api/v2/jobs/{id}` does (unknown, not-yours, or past

        its retention deadline). Nothing ages a log out ahead of the job''s

        own `expires_at`, so a job never outlives its log.


        Live tailing is not offered here yet. When it is, it arrives on this

        same path under `Accept: text/event-stream`, leaving this

        JSON snapshot the default; its resume semantics will be defined

        then, against a capture that is incremental. Until then the SSE

        `log` event on `GET /api/v2/jobs/{id}/events` is the reserved live

        rail, and this is the authoritative snapshot it reconciles against.

        '
      parameters:
      - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: The captured log.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobLogs'
        '204':
          description: This job has no log. A normal answer, not an error — see the description for the cases it covers.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/UpstreamError'
  /api/v2/jobs/{id}/events:
    get:
      operationId: getJobEvents
      tags:
      - jobs
      summary: Live event stream (SSE)
      description: 'Server-Sent Events stream of the job''s live state. On connect the

        client receives the current snapshot (a `status` event, the latest

        `progress`, and the most recent `preview` if any), then future

        updates. The stream ends after the terminal `status` event.


        Live push only — NOT a replayable log: events carry no `id`, there

        is no `Last-Event-ID` resume, and frames emitted while disconnected

        are gone. Each `progress` event is a complete snapshot, so a single

        one fully re-syncs a reconnecting client; the authoritative state is

        always `GET /api/v2/jobs/{id}`.

        '
      x-sse-events:
        status:
          description: Every lifecycle transition, including the initial state on connect and queue_position updates while queued.
          schema: '#/components/schemas/StatusEvent'
        progress:
          description: Node- and step-level progress, throttled server-side (~2/s). Complete snapshot per event.
          schema: '#/components/schemas/Progress'
        preview:
          description: In-progress preview image (JPEG, base64), throttled; server keeps only the most recent, pushed on connect.
          schema: '#/components/schemas/PreviewEvent'
        output:
          description: 'Emitted the moment each output asset is committed, carrying the same `Output` object that appears on `job.outputs[]`. A latency optimization only: it lets a client render each result as it lands instead of waiting for the terminal `status` event. It is delivered best-effort over the live broadcast path — an output whose durable asset record is not yet resolvable when its node finishes may be delivered on a slightly later event or, failing that, only in the terminal `status` snapshot — so the authoritative, complete set of outputs is always `job.outputs[]` on `GET /api/v2/jobs/{id}` and on the terminal `status` event. A client must therefore treat these as additive hints and must not assume it receives one per output.'
          schema: '#/components/schemas/Output'
        log:
          description: 'Selected execution log lines, carried while the run is still going. Best-effort diagnostics, and lossy by the same rule as the rest of this stream: lines emitted while a client was disconnected are gone and no `Last-Event-ID` replays them. The authoritative, complete log is the snapshot at `GET /api/v2/jobs/{id}/logs`, which a client re-reads after a terminal status to reconcile whatever it missed — on a surface that captures logs at all. Comfy Cloud does not, and answers `204` there for every job, so this event has nothing to be the live view of; see that operation for what a self-hosted deployment answers. NOT YET EMITTED by the server in the first iteration — reserved in the catalog so the wire contract is stable. Clients must not depend on receiving this event yet: to get a log today, stream to a terminal status and read the snapshot.'
          x-sse-not-yet-emitted: true
          schema: '#/components/schemas/LogEvent'
      parameters:
      - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: SSE stream; see `x-sse-events` for the event catalog.
          content:
            text/event-stream:
              schema:
                type: string
                description: Stream of `event:`/`data:` frames. Data payloads are the JSON schemas listed in x-sse-events.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          description: '`too_many_streams` — the caller already has the maximum number of concurrent GET .../events streams open. Close an existing stream (or wait for one to reach a terminal status) before opening another; GET /api/v2/jobs/{id} remains available as a plain poll regardless of this limit.'
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '501':
          description: '`not_implemented` — this deployment does not yet serve live event streaming. GET /api/v2/jobs/{id} remains available as a plain poll in the meantime.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          $ref: '#/components/responses/UpstreamError'
  /api/v2/jobs/{id}/cancel:
    post:
      operationId: cancelJob
      tags:
      - jobs
      summary: Request cancellation
      description: 'Requests cancellation and returns the current job object —

        `canceling` (interruption takes effect at node/step boundaries) or

        already-terminal. Idempotent: canceling a finished job is a no-op

        returning the terminal state. On serverless, GPU seconds consumed

        before the interrupt takes effect are still billed.

        '
      x-retryable: true
      parameters:
      - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: Current job state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/UpstreamError'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: '`Authorization: Bearer <api-key>` — account-scoped API keys on Cloud and serverless. Self-hosted accepts unauthenticated requests by default and can be configured with a static bearer token.'
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
      description: 'Client-generated UUID (recommended). Single-use: the first request to present a key is processed; any later request with the same key is rejected `422` `idempotency_key_reuse` (reject-on-duplicate, no response replay). Keys expire after 24h.'
    JobId:
      name: id
      in: path
      required: true
      schema:
        type: string
      example: 7f3d2c1b-9a8e-4d6f-b012-3c4d5e6f7a8b
    AssetId:
      name: id
      in: path
      required: true
      schema:
        type: string
      example: 9f8a1c0d-2b3e-4f56-8a7b-1c2d3e4f5a6b
    BlakeHash:
      name: hash
      in: path
      required: true
      schema:
        type: string
      description: Content hash, written `blake3:<hex>`.
      example: blake3:9f8a1c0d...
  headers:
    RetryAfter:
      schema:
        type: integer
      description: Seconds to wait before retrying.
  responses:
    Unauthorized:
      description: '`unauthorized` — missing or invalid credentials.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    Forbidden:
      description: '`forbidden` — authenticated but not allowed.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    NotFound:
      description: '`not_found`.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    UpstreamError:
      description: '`upstream_error` — an unexpected failure reaching or processing the request in this implementation''s backing services. The message is always a generic, safe-to-display string; implementation detail (the specific upstream, its error text, transport failures) is never included here — see each implementation''s own error-mapping notes. Every operation in this contract can fail this way.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    RateLimited:
      description: '`rate_limited` — the caller has exceeded the request rate limit for this account. Account/rate-scoped, not job-specific — this can be returned even for a job id the caller doesn''t own or that doesn''t exist, without revealing which.'
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  schemas:
    Asset:
      type: object
      description: 'A user-owned record identified by a server-assigned UUID, backing an immutable blob whose content carries a server-computed blake3 hash. `hash` may be computed lazily: an asset record (and its retrievable bytes) can exist before its hash is filled in.'
      required:
      - id
      - hash
      - size_bytes
      - content_type
      - created_at
      - url
      - url_expires_at
      properties:
        id:
          type: string
          example: 9f8a1c0d-2b3e-4f56-8a7b-1c2d3e4f5a6b
        hash:
          type: string
          nullable: true
          description: '`blake3:<hex>`; null while lazily computed.'
          example: blake3:9f8a1c0d...
        size_bytes:
          type: integer
          format: int64
          example: 4816293
        content_type:
          type: string
          example: image/png
        file_path:
          type: string
          nullable: true
          example: photo.png
        created_new:
          type: boolean
          description: 'On create responses: distinguishes a brand-new blob (true) from a dedup hit against bytes the platform already had (false).'
        created_at:
          type: string
          format: date-time
        url:
          type: string
          format: uri
          description: Short-lived content URL (signed, or proxy-served).
        url_expires_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: 'Retention deadline for the asset itself (distinct from `url_expires_at`, the signed URL''s validity). Null or absent means the asset is non-expiring. On a dedup-hit create response the deadline may be later than now + the requested/default retention: re-referencing content extends its retention, never shortens it.'
        job_id:
          type: string
          nullable: true
          description: ID of the job that produced this asset. Absent for uploaded assets, which have no producing job.
    Job:
      type: object
      description: One execution of a workflow. Durable from creation until `expires_at`; `outputs` populates incrementally during execution.
      required:
      - id
      - status
      - created_at
      - started_at
      - completed_at
      - expires_at
      - queue_position
      - progress
      - outputs
      - error
      - urls
      properties:
        id:
          type: string
          example: 7f3d2c1b-9a8e-4d6f-b012-3c4d5e6f7a8b
        status:
          $ref: '#/components/schemas/JobStatus'
        created_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        expires_at:
          type: string
          format: date-time
          description: Retention deadline — a platform property, not an API constant.
        queue_position:
          type: integer
          nullable: true
        progress:
          allOf:
          - $ref: '#/components/schemas/Progress'
          nullable: true
          description: The latest progress snapshot; same data the SSE stream pushes.
        outputs:
          type: array
          items:
            $ref: '#/components/schemas/Output'
        error:
          allOf:
          - $ref: '#/components/schemas/JobError'
          nullable: true
        metrics:
          type: object
          description: 'Values are nullable (a metric not yet available — e.g. `execution_ms` before a job starts running — is `null`, not omitted); the example below is deliberately all-non-null purely to work around a Spectral/nimma lint-tooling crash on a literal `null` inside a schema `example` combined with `additionalProperties.nullable: true` — the schema itself is unchanged and still allows null values at runtime.'
          additionalProperties:
            type: integer
            nullable: true
          example:
            queue_ms: 9000
            execution_ms: 42000
        urls:
          $ref: '#/components/schemas/JobUrls'
    JobLogs:
      type: object
      description: 'A job''s captured execution log — the body of `GET /api/v2/jobs/{id}/logs`. Diagnostics, not a contract on content: this is whatever the workflow''s own code and nodes wrote to standard output, in the order they wrote it, so nothing about its shape is stable between runs or between releases of a build. It is **untrusted text** — a workflow chooses what goes in it — and must be rendered as plain text rather than interpreted.'
      required:
      - text
      - truncated
      - captured_at
      - complete
      properties:
        text:
          type: string
          description: The captured output.
        truncated:
          type: boolean
          description: 'The BEGINNING of the captured output was discarded — `text` is the TAIL of a longer run. Implementations bound what they capture and store, so a workflow that prints megabytes keeps its last lines, where a failure normally is, instead of being dropped whole. True with an empty `text` means the log was captured and then shed entirely to fit. This describes the stored log, never the response: it does not mean a caller asked for part of one.'
        captured_at:
          type: string
          format: date-time
          description: When the run's output was read back off the worker.
        complete:
          type: boolean
          description: No further output will be appended to this log. Always `true` today, because a log is read back off the worker once, when the run ends, so a log that exists is already whole. Sent so that a surface which later captures output while a run is still going can say so, and a client written now against `false` keeps working when it does. `false` does not promise that more output will arrive, only that this snapshot may not be the last one.
    JobWorkflowResponse:
      type: object
      description: The workflow behind a job. See GET /api/v2/jobs/{id}/workflow's description for exactly when `format` is `save` vs `api`.
      required:
      - workflow
      - format
      properties:
        workflow:
          type: object
          description: The workflow, verbatim, in the shape `format` says.
          additionalProperties: true
        format:
          type: string
          enum:
          - save
          - api
          description: 'Discriminates the `workflow` field''s shape. `save`: the original authoring workflow JSON, at the version pinned to the job. `api`: the executed API-format prompt graph.'
    JobStatus:
      type: string
      enum:
      - queued
      - running
      - succeeded
      - canceling
      - canceled
      - failed
      - expired
      description: 'Lifecycle: queued → running → succeeded | failed | expired;

        a cancel request moves running → canceling → canceled.

        Terminal states: succeeded, canceled, failed, expired.

        '
    JobUrls:
      type: object
      description: Embedded follow-up links — follow these, don't build URLs. A link is either an absolute URL or a host-relative reference (leading `/`) that already includes any prefix the serving surface is mounted under (e.g. a serverless gateway's `/deployment/{deployment_id}/api/v2`). Clients MUST resolve a host-relative link against the request origin (scheme + authority), never against a configured base URL — joining it to a base URL that carries the same mount prefix duplicates the prefix.
      required:
      - self
      - events
      - cancel
      properties:
        self:
          type: string
          format: uri-reference
        events:
          type: string
          format: uri-reference
        cancel:
          type: string
          format: uri-reference
        logs:
          type: string
          format: uri-reference
          description: 'Where to read what this run printed. Present on any surface that captures execution logs, which is why it is the one link here that is optional: absent means this surface captures none, for any job, so a client can stop looking without spending a request on an answer it already has.

            Follow this link rather than building the path from the job id. The two are not interchangeable: a surface may be mounted under a prefix this link already carries and a hand-built path would not, and a surface that does not implement the operation at all answers a routing `404` — indistinguishable, to the client, from the `404` that means the job itself is gone. Present does NOT mean this job has a log, and it is deliberately not a signal about one: a surface that captures logs offers the link on every job, including those it will answer `204` for and those whose log it withholds. Read the log, not the link.'
    Progress:
      type: object
      description: Server-computed progress snapshot (node-count and sampler-step weighted). Complete per snapshot — one fully re-syncs a client.
      required:
      - value
      - nodes_done
      - nodes_total
      properties:
        value:
          type: number
          format: double
          minimum: 0
          maximum: 1
          description: Overall fraction, server-computed.
          example: 0.42
        nodes_done:
          type: integer
          example: 11
        nodes_total:
          type: integer
          example: 31
        current_node:
          type: string
          nullable: true
          example: '12'
        current_node_class:
          type: string
          nullable: true
          example: KSampler
        step:
          type: integer
          nullable: true
          example: 21
        steps:
          type: integer
          nullable: true
          example: 50
        message:
          type: string
          nullable: true
          example: KSampler 21/50
    Output:
      type: object
      description: 'A committed job output. Outputs are assets: `id` is the asset UUID, retrievable via GET /api/v2/assets/{id} for as long as the job is retained. `hash` is lazily computed and may be null on the retrieval hot path.'
      required:
      - node_id
      - name
      - type
      - content_type
      - size_bytes
      - id
      - hash
      - url
      - url_expires_at
      properties:
        node_id:
          type: string
          description: The workflow node that reported this file; empty when the worker named none.
          example: '9'
        name:
          type: string
          example: ComfyUI_00001_.png
        type:
          $ref: '#/components/schemas/OutputType'
        content_type:
          type: string
          example: image/png
        size_bytes:
          type: integer
          format: int64
          example: 1848320
        id:
          type: string
          description: Asset UUID.
          example: 9f8a1c0d-2b3e-4f56-...
        hash:
          type: string
          nullable: true
          description: '`blake3:<hex>`; null until lazily computed.'
        url:
          type: string
          format: uri
        url_expires_at:
          type: string
          format: date-time
        job_id:
          type: string
          nullable: true
          description: ID of the job that produced this output.
    OutputType:
      type: string
      enum:
      - image
      - video
      - audio
      - text
      - file
      - latent
      description: Normalized output kind — nothing silently dropped.
    JobError:
      type: object
      description: Execution failure detail, carried in `job.error` (not an HTTP error).
      required:
      - code
      - message
      properties:
        code:
          type: string
          example: node_execution_error
        message:
          type: string
        node_id:
          type: string
          nullable: true
        class_type:
          type: string
          nullable: true
        traceback:
          type: string
          nullable: true
    ErrorEnvelope:
      type: object
      description: 'Shared error envelope with machine-readable codes. Core codes (v1):

        `invalid_workflow` (422), `workflow_format_ui` (422),

        `missing_asset` (422), `hash_mismatch` (409), `blob_not_found`

        (404), `idempotency_key_reuse` (422),

        `queue_full` (429 + Retry-After), `insufficient_credits` (402),

        `not_found` (404), `unauthorized` (401), `forbidden` (403).

        Deployment-scoped surfaces add: `deployment_not_ready` (429 +

        Retry-After — the deployment can still reach ready; retry) and

        `deployment_stopped` (422 — terminal deployment state; a retry

        cannot succeed without operator action). A 429 is disambiguated

        by `error.code` alone; clients should treat any 429 + Retry-After

        as "back off and retry".

        '
      required:
      - error
      properties:
        error:
          type: object
          required:
          - code
          - message
          properties:
            code:
              type: string
              example: invalid_workflow
            message:
              type: string
              example: 'Node 12 (KSampler): required input ''model'' is not connected'
            details:
              type: object
              nullable: true
              additionalProperties: true
              example:
                node_errors:
                  '12':
                  - field: model
                    reason: missing_input
    StatusEvent:
      type: object
      description: SSE `status` event payload.
      required:
      - status
      properties:
        status:
          $ref: '#/components/schemas/JobStatus'
        queue_position:
          type: integer
          nullable: true
    PreviewEvent:
      type: object
      description: SSE `preview` event payload (JPEG, base64, throttled).
      required:
      - node_id
      - content_type
      - data_base64
      properties:
        node_id:
          type: string
        content_type:
          type: string
          example: image/jpeg
        data_base64:
          type: string
    LogEvent:
      type: object
      description: SSE `log` event payload. Best-effort diagnostics.
      required:
      - level
      - message
      properties:
        level:
          type: string
          example: info
        message:
          type: string
    AssetReference:
      type: object
      description: "The typed asset-reference object placed inside workflow JSON where a\nfilename would normally go (documented here for tooling; it is not a\nrequest/response body itself):\n\n    {\"__type\": \"core/ASSET\",\n     \"info\": {\"id\": \"<asset-uuid>\", \"hash\": \"blake3:...\",\n              \"file_path\": \"photo.png\"}}\n\n`info.id` (the asset UUID) is required in v1 and authoritative;\n`hash` and `file_path` are optional staging/lookup hints and never\noverride a present `id`. A malformed reference or one that is not\nresolvable/owned by the caller fails submission with 422\n`missing_asset`.\n"
      required:
      - __type
      - info
      properties:
        __type:
          type: string
          enum:
          - core/ASSET
        info:
          type: object
          required:
          - id
          properties:
            id:
              type: string
              example: 9f8a1c0d-2b3e-4f56-8a7b-1c2d3e4f5a6b
            hash:
              type: string
              example: blake3:9f8a1c0d...
            file_path:
              type: string
              example: photo.png
