Create or reuse an asset over existing bytes
Zero-byte fast-path: creates or reuses an asset over a blob the
Create or reuse an asset over existing bytes
Zero-byte fast-path: creates or reuses an asset over a blob the
platform already has, identified by its blake3 hash.
On Comfy Cloud, the caller must already own the content in the current workspace. Content owned only by another member of that workspace does not satisfy the ownership check and returns 404 blob_not_found, just like an absent hash. A matching caller-owned Cloud asset is reused with 200, created_new: false and its UUID, name and tags, even when the request supplies a different valid file_path or tags.
On a deployment, reuse matches both the hash and the exact path in the build's scope. A match returns 200 with created_new: false; a different path over content the caller may use creates a new reference with 201 and created_new: true. The self-hosted proxy returns its existing hash-indexed record with 200 and created_new: false, without applying the requested path or tags.
Reuse keeps the existing name and tags. Cloud and deployment validation still applies to submitted metadata before reuse; metadata is not exempt from validation because it will not replace stored values. Retention may refresh, and content URLs are generated for the response, so reuse does not promise an unchanged response.
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).
The lookup key is the hash an upload returned, which is the hash of the stored bytes. When location metadata was removed from an image before it was stored, that differs from the client's digest of the file it sent, and the client's digest finds nothing.
OpenAPI
````yaml /openapi-v2.yaml post /api/v2/assets/from-hash openapi: 3.0.3 info: title: Comfy API v2 version: 2.0.0
description: |
The official, versioned HTTP API for running ComfyUI workflows from external applications: upload inputs, submit a workflow, observe execution, retrieve results.
Design principles:
- Poll-first. Every capability is reachable via plain GET polling; the SSE stream is a live enhancement, never the source of truth.
- Everything is resumable. Submission is idempotent; job state and outputs are retrievable by ID until
expires_at. - UUID identity, content-addressed dedup. Assets are UUID-identified records over blobs keyed by a server-computed blake3 hash. The hash is nullable and may be computed lazily.
- Follow links, don't build URLs. Responses embed follow-up URLs.
Additive changes only within v2; breaking changes require v3. 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/from-hash: post: tags:
- assets summary: Create or reuse an asset over existing bytes description: | Zero-byte fast-path: creates or reuses an asset over a blob the platform already has, identified by its blake3 hash.
On Comfy Cloud, the caller must already own the content in the current workspace. Content owned only by another member of that workspace does not satisfy the ownership check and returns 404 blob_not_found, just like an absent hash. A matching caller-owned Cloud asset is reused with 200, created_new: false and its UUID, name and tags, even when the request supplies a different valid file_path or tags.
On a deployment, reuse matches both the hash and the exact path in the build's scope. A match returns 200 with created_new: false; a different path over content the caller may use creates a new reference with 201 and created_new: true. The self-hosted proxy returns its existing hash-indexed record with 200 and created_new: false, without applying the requested path or tags.
Reuse keeps the existing name and tags. Cloud and deployment validation still applies to submitted metadata before reuse; metadata is not exempt from validation because it will not replace stored values. Retention may refresh, and content URLs are generated for the response, so reuse does not promise an unchanged response.
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).
The lookup key is the hash an upload returned, which is the hash of the stored bytes. When location metadata was removed from an image before it was stored, that differs from the client's digest of the file it sent, and the client's digest finds nothing. operationId: assetFromHash requestBody: required: true content: application/json: schema: type: object required:
- hash properties: hash: type: string example: blake3:9f8a1c0d... file_path: type: string description: >- Placement path / filename for a new reference. Comfy Cloud uses its last segment as the name; a deployment uses the full path. Both default to the bare hash digest when omitted or empty. Cloud reuse keeps the existing name even if a different valid path is supplied; a deployment also matches the path when deciding reuse. The self-hosted proxy keeps its indexed record's path. On Comfy Cloud a
file_paththat fails path validation, or whose last segment breaks thefile_pathrules ofPOST /api/v2/assets, is answered with 404blob_not_found, the same as an unknown hash. The UTF-8 rule cannot fire here: invalid UTF-8 bytes and lone surrogate escapes (\ud800) are replaced with U+FFFD when the JSON body is decoded, so such a name is stored with U+FFFD in their place. A deployment answers afile_paththat fails path validation or holds a control character (C0, DEL, C1) or a bidi embedding, override or isolate (U+202A-U+202E, U+2066-U+2069) with 422invalid_request; the same U+FFFD replacement applies there. example: photo.png tags: type: array items: type: string description: >- Tags for a new reference; reuse keeps the existing tags. Comfy Cloud defaults an omitted or empty set toinput. Cloud and deployment validation still applies on reuse; the self-hosted proxy does not apply submitted tags. A create carries at most 64 tags, each a non-empty UTF-8 string of at most 256 bytes (surrounding whitespace included) with no NUL byte. A deployment addsinputto every asset and counts it, so it takes at most 63 of the caller's own; themodel_type:tags derived for a model asset are not counted. A tag set breaking a limit is refused with 422. Invalid UTF-8 or a lone surrogate escape (e.g.\ud800) inside a JSON-encoded tag is not refused: JSON decoding replaces it with U+FFFD (3 bytes each), and the 256-byte limit applies to the replaced value. expires_in: type: integer minimum: 60 maximum: 604800 description: >- Optional retention override in seconds (60s–7d): the asset'sexpires_atbecomes 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: '200': description: >- An existing asset was reused under the serving surface's rules above;created_newis false. Its UUID, name and tags are kept; retention may refresh and content URLs are generated for this response. content: application/json: schema: $ref: '#/components/schemas/Asset' '201': description: >- A new asset reference with a new UUID was created over existing bytes;created_newis true. Supplied metadata and defaults apply as described above. 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' '422': description: >- Thetagsorexpires_inis malformed,tagsbreaks the limits above or holds a disallowed tag, or (on a deployment) thefile_pathis malformed:invalid_requeston a deployment. On Cloud a tag with a malformed shape isinvalid_body, and a refusal by the platform (for example a disallowed tag) isvalidation_error. Cloud answers a malformedfile_pathwith 404blob_not_foundinstead (seefile_path). A deployment checks the tags even when an identical reference exists. content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/UpstreamError' '503': $ref: '#/components/responses/DeploymentLookupUnavailable' components: 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.hashmay be computed lazily: an asset record (and its retrievable bytes) can exist before its hash is filled in. On an output,job_metadatacarries the labels its producing job was submitted with, so code holding only the asset knows which run it came from. It is absent for assets no job produced, for outputs of a job sent without labels, where the serving surface keeps no job labels, and when the labels could not be read: sojob_idwithoutjob_metadatadoes not prove the job had no labels. A create whose file path and bytes match an existing output answers with that output, so itsjob_idandjob_metadataare the output's. 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: >- OnPOST /api/v2/assetsresponses, distinguishes a brand-new blob (true) from a dedup hit against bytes the platform already had (false). OnPOST /api/v2/assets/from-hashresponses, true means a new asset reference was created over existing bytes (201); false means an existing asset was reused (200). 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 fromurl_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 assets no job produced, such as a fresh upload. job_metadata: allOf: - $ref: '#/components/schemas/JobMetadata' description: >- The labels the producing job was submitted with, read through
job_idon each read. Absent for assets no job produced, for outputs of a job sent without labels, where the serving surface keeps no job labels, when the job is gone, and when the labels could not be read: sojob_idwithoutjob_metadatadoes not prove the job had no labels. 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),upload_timed_out(422: an asset upload stopped sending or was too slow; retry from a faster connection),queue_full(429 + Retry-After),rate_limited(429 + Retry-After: the caller is past a request rate limit; retry),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),deployment_unavailable(429 + Retry-After: the deployment is ready but its GPU provider is not taking work on it yet; retry),deployment_lookup_unavailable(503 + Retry-After, on any operation: the deployment the address names could not be looked up just now; nothing was done, so retry),database_unavailable(503 + Retry-After, only on a job submit: the job could not be saved just now and nothing was started, so retry),auth_unavailable(503 + Retry-After, on any operation: the credential could not be checked just now, which says nothing about the credential, so retry),output_unavailable(503 + Retry-After, only on a job status read: an output of the job could not be prepared just now; the job is unchanged, so retry afterRetry-After, and report the job if it keeps answering),deployment_stopped(422 — terminal deployment state; a retry cannot succeed without operator action),invalid_request(422: a malformed asset upload or asset-from-hash field, or a jobs-listlimitthat is not a positive integer),validation_error(422, Cloud: an asset upload or asset-from-hash field was refused, e.g. a tag over the tag limits),content_blocked(451: content moderation flagged the asset's bytes) andsso_required(403: the key is valid, but the account must sign in through its organization's single sign-on, which does not accept this key). Comfy Cloud's asset upload addsinvalid_content_type(422: the request is notmultipart/form-datawith a boundary),invalid_body(422: a malformed multipart body or upload field),validation_error(422: the platform refused the upload, for example an image over the size cap or a disallowed tag) andupload_busy(503 + Retry-After: the server is busy with other uploads and nothing was stored, so retry). Job labels add:metadata_invalid(422: a submittedmetadatabreaks a limit;details.keynames the key, except for too many pairs, which has nodetails),metadata_not_supported(422: this surface keeps no job labels yet),invalid_metadata_filter(400) andinvalid_cursor(400). A surface that does not list jobs yet answersnot_implemented(501). A 429 is disambiguated byerror.codealone; 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' organization_id: type: string description: >- On
sso_required: the organization whose single sign-on governs this key, the one that holds the account, else the one that holds the key's workspace. It is theorganizationquery parameter of Comfy Cloud's single sign-on start; treat it as opaque. Absent when the organization is unknown, and on every other code. example: org_01HXYZEXAMPLE details: type: object nullable: true additionalProperties: true description: >- Machine-readable detail for the code. When it carriesnode_errors, that is keyed by node id and each value is aJobNodeError, the same shape as a job'serror.node_errors, whether the refusal came at submit (for exampleunknown_node_class, a node class the deployment's build does not contain) or from ComfyUI after dispatch. Withunknown_node_classerrors it carriesunknown_node_classes, the missing classes; withwaits_for_browsererrors,browser_wait_node_classes, the classes that wait for a browser. A submit refusal names at most fifty nodes. Forinvalid_node,invalid_inputsandinvalid_class_type, when it found more than fifty,node_errors_truncatedis true andmalformed_node_countcarries the full count; forwaits_for_browser,browser_wait_node_countdoes. Forunknown_node_classit names only nodes whose class is one of the at most ten listed inunknown_node_classes; when those nodes number more than fifty,node_errors_truncatedis true andunknown_node_countis how many nodes have a listed class. Nodes whose class is past those ten are neither named nor counted. example: node_errors: '12': class_type: SomeCustomNode errors: - type: unknown_node_class message: >- this deployment's build does not contain the node class SomeCustomNode. unknown_node_classes:
- SomeCustomNode JobMetadata: type: object description: >- The labels the job was submitted with, exactly as sent. Absent when it was sent with none. additionalProperties: type: string example: client: acme responses: Unauthorized: description: >-
unauthorized: missing or invalid credentials. A credential that could not be checked answersauth_unavailable(503) instead. content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' Forbidden: description: >-forbidden— authenticated but not allowed. On a serverless deployment, alsosso_required— the key is valid, but the account must sign in through its organization's single sign-on, which does not accept this key; it can come back on any operation. content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' RateLimited: description: >-rate_limited— the caller has exceeded a request rate limit for this account. Account/rate-scoped, not resource-specific — this can be returned even for a job, asset or deployment the caller doesn't own or that doesn't exist. headers: Retry-After: $ref: '#/components/headers/RetryAfter' 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' DeploymentLookupUnavailable: description: >- On deployment-scoped surfaces,deployment_lookup_unavailable: the deployment this address names could not be looked up just now. The request was refused before it did anything (nothing was created, read, changed or canceled), so the same request can be sent again afterRetry-After. Every operation at a deployment's address can answer this way. A job submit can also answer this 503 withdatabase_unavailable: the job could not be saved just now and nothing was started, so the same request can be sent again afterRetry-After. No other operation answersdatabase_unavailable. On Comfy Cloud an asset upload can also answer this 503 withupload_busy: the server is busy with other uploads and nothing was stored, so the same upload can be sent again afterRetry-After. Any operation can also answer this 503 withauth_unavailable: the credential could not be checked just now, so the same request can be sent again afterRetry-After. A job status read can also answer it withoutput_unavailable: an output of the job could not be prepared just now, and the job is unchanged. A transient storage failure clears on a later read afterRetry-After; an output whose stored address is broken answers it on every read until the platform repairs the output. headers: Retry-After: $ref: '#/components/headers/RetryAfter' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' headers: RetryAfter: schema: type: integer description: Seconds to wait before retrying. securitySchemes: bearerAuth: type: http scheme: bearer description: >-Authorization: Bearer <credential>. The credential is one of: an account-scoped API key (comfyui-…), accepted on Cloud and serverless; a Comfy Cloud session JWT; or an OAuth access token issued for the Comfy Cloud resource. Which kinds a given deployment accepts is deployment configuration — an API key always works on Cloud and serverless, and a deployment that does not accept JWT bearers answers401with a message saying so. Self-hosted accepts unauthenticated requests by default and can be configured with a static bearer token.
````