{{ message }}
Repository navigation
Expand file tree
/
Copy pathopenapi-v2.yaml
More file actions
1745 lines (1499 loc) · 83.2 KB
/
Copy pathopenapi-v2.yaml
File metadata and controls
1745 lines (1499 loc) · 83.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# 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.
If location metadata (GPS, XMP, IPTC) is removed from an image before
it is stored, the asset''s `hash` is the hash of the stored bytes and
can differ from `expected_hash`. On Comfy Cloud, an upload whose
bytes open as a JPEG, PNG, WebP or classic TIFF (including TIFF-based
camera RAW such as DNG, NEF, ARW and CR2, but not BigTIFF) and that
is over 50 MiB is refused with 422 `validation_error`, whatever its
`content_type` and whether or not it carries location metadata. A
request that declares a `Content-Length` over Comfy Cloud''s 200 MB
(200,000,000-byte) body limit is refused before that check with a
plain 413 that does not use the `ErrorEnvelope` shape. A body sent
without a declared length is cut off when it passes that limit, and
no 413 is sent: a cut-off while the fields before `file` are read
fails with 422 `invalid_body`, and a cut-off inside the file usually
fails with 500 `upstream_error`. Nothing after the `file` field is
read, so a body that only passes the limit there is uploaded
normally.
Deployment-scoped surfaces neither strip location metadata nor apply
the 50 MiB image cap; they limit the request body to 110 MB
(110,000,000 bytes). A request that declares a `Content-Length` over
that limit is refused with the same plain 413; a body sent without a
declared length is cut off when it passes the limit, and the upload
then fails with an error response that is not guaranteed to be a 413.
The uploaded asset is referenceable immediately; any content scanning
runs in the background and does not block the response.
An upload is a single request. v1 expects files of up to ~100 MB;
the hard cap is the body limit of the surface it is sent to (above).
Chunked/resumable large uploads are a deliberate open question.
On Comfy Cloud an upload fails with 422 `upload_timed_out` when no
request bytes arrive for 30 seconds, or when the whole request takes
longer than 10 minutes, however steadily bytes keep arriving.
On Comfy Cloud an upload can also be refused with 503 `upload_busy`
and `Retry-After` when the server is busy with other uploads: nothing
was stored, so the same upload can be sent again after `Retry-After`.
On a Developer Platform deployment the same 422 `upload_timed_out`
applies when no request bytes arrive for 30 seconds, or when the whole
request takes longer than 20 minutes.
'
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
description: The asset's media type. On Comfy Cloud it must be valid UTF-8, not blank, at most 255 bytes, must not start or end with whitespace, and must not contain control characters (C0, DEL, C1, U+2028, U+2029) or any invisible format or default-ignorable character (every Unicode general category Cf character, including the zero-width joiners and direction marks, and the other default-ignorable code points); otherwise the request is refused with 422 `invalid_body` before anything is stored. An accepted value is stored as sent.
file_path:
type: string
description: Placement path / filename (global-namespace-root form, e.g. `photo.png` or `models/checkpoints/x.safetensors`). On Comfy Cloud the last segment becomes the asset name, so it must be valid UTF-8, at most 255 bytes, must not start with `.`, and must not contain control characters (C0, DEL, C1, U+2028, U+2029) or invisible format and default-ignorable characters (Unicode general category Cf, which includes the bidi embedding, override and isolate controls, soft hyphen, zero-width space, word joiner, BOM and tag characters, plus the other default-ignorable code points such as U+034F, U+2065 and the Hangul fillers); otherwise the request is refused with 422 `invalid_body` before anything is stored. ZWNJ/ZWJ (U+200C, U+200D) and the direction marks U+200E, U+200F and U+061C are allowed, for right-to-left and emoji names, and so are the visible Arabic number signs (prepended concatenation marks such as U+0600) and variation selectors; the direction marks can still change how a name is displayed.
example: photo.png
expected_hash:
type: string
description: 'Optional client-computed blake3 (`blake3:<hex>`) of the bytes sent. Omit the part to skip verification: on Comfy Cloud and on a deployment a part sent empty is refused with 422 as malformed. Send it before `file`: nothing after the `file` field is read, so a trailing `expected_hash` is neither verified nor refused. Verified against the server-computed blake3 of the received bytes, not of the stored bytes; mismatch is rejected with 409 `hash_mismatch`. On serverless deployments no asset is minted and no existing asset''s retention is changed; elsewhere the upload is stored before the check, so the 409 returns no asset and a retry with the corrected expectation dedups onto it.'
example: blake3:9f8a1c0d...
tags:
type: array
items:
type: string
description: 'Category tags (e.g. `input`). 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 adds `input` to every asset and counts it, so it takes at most 63 of the caller''s own; the `model_type:` tags derived for a model asset are not counted. A tag set breaking a limit is refused with 422. The rest of this paragraph describes Cloud''s hosted API and a deployment, not the self-hosted proxy. Both cap the bytes of all `tags` parts sent before `file` combined: 8 KiB on Cloud''s hosted API (422 `invalid_body`) and 64 KiB on a deployment (422 `invalid_request`). The cap counts duplicates before they are removed and JSON escapes before they are decoded, and is checked before the count and 256-byte limits above, so a tag set within them can still be refused. A `quoted-printable` part is counted and read after it is decoded; a part in any other `Content-Transfer-Encoding` is read as sent, so a `base64` part is stored as one tag holding its encoded text, while a `7bit`, `8bit` or `binary` part is read like a part with no encoding. A part is read as a JSON array when it starts with `[` (after leading whitespace) and is valid JSON or continues, after optional whitespace, with `"`: `["a","b"]` is two tags, `[]` adds no tags (nor does an empty part), and a malformed array such as `[1]` or `["a` is refused with 422. Any other non-empty part is one bare tag, kept as literal text, so `foo\ud800` sent as a bare part is stored as written. Invalid UTF-8 in a `tags` part, or a lone surrogate escape (e.g. `\ud800`) in a JSON-array `tags` part, is refused with 422 (`invalid_body` on Cloud, `invalid_request` on a deployment).'
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'
'413':
description: The upload is over the body limit. On Comfy Cloud (200 MB, 200,000,000 bytes) and on deployment-scoped surfaces (110 MB, 110,000,000 bytes) the request declared a `Content-Length` over it; the server's body limit sends this before the upload is handled, so the body is not an `ErrorEnvelope` and its shape is not part of this contract. The self-hosted proxy instead refuses a file over its configured cap (100 MiB by default) with an `ErrorEnvelope` whose code is `payload_too_large`.
'422':
description: '`idempotency_key_reuse`, `input_blocked` (the bytes are already stored and flagged by content moderation, so `file_path` is not registered for them), `upload_timed_out` (the client stopped sending or sent too slowly; the server cannot recover the upload, so retry it from a faster connection), or validation failure — including a `tags` set over the limits above or a disallowed tag (`invalid_request` on a deployment or the self-hosted proxy; a deployment answers a body that is not `multipart/form-data` with a boundary with a plain 500 instead). On a deployment that includes a truncated `file` part: the body ends inside the part, before the delimiter that ends it. A body that stops right at that delimiter, without the final `--`, is not rejected: the part reads as complete and is stored. Comfy Cloud''s asset upload answers validation failure instead with `invalid_content_type` (the request is not `multipart/form-data` with a boundary), `invalid_body` (a malformed multipart body or upload field) or `validation_error` (the platform refused the upload, for example an image over the size cap or a disallowed tag). Cloud refuses a malformed multipart body with `invalid_body` when the fault is found before the `file` part. Cloud stops parsing at `file`, so parts after it are never read, and a body that ends inside the streamed file currently gets 500 `upstream_error` instead. The exception is an upload with `expected_hash`: if Cloud accepts the upload before the whole file is read, it reads the rest. A body that ends inside the `file` part, before its delimiter, then gets `invalid_body`, and a complete body gets 500 `upstream_error` instead of 201 or 409; in both cases the asset is already stored, holding only the part of the file read before the upload was accepted. `invalid_body` also covers `tags` parts over the 8 KiB combined cap described under `tags`, and a body sent without a declared length that is cut off at the body limit, as described for this operation. On Comfy Cloud and on a deployment, an `expected_hash` part that is present but empty is a malformed field (`invalid_body` on Comfy Cloud, `invalid_request` on a deployment), not an omitted hash.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'429':
$ref: '#/components/responses/RateLimited'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'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`).
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.
'
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; its last segment becomes the asset name. On Comfy Cloud a `file_path` that fails path validation, or whose last segment breaks the `file_path` rules of `POST /api/v2/assets`, is answered with 404 `blob_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.'
example: photo.png
tags:
type: array
items:
type: string
description: '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 adds `input` to every asset and counts it, so it takes at most 63 of the caller''s own; the `model_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''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'
'422':
description: 'The `tags` or `expires_in` is malformed, `tags` breaks the limits above or holds a disallowed tag, or (on a deployment) the `file_path` is malformed: `invalid_request` on a deployment. On Cloud a tag with a malformed shape is `invalid_body`, and a refusal by the platform (for example a disallowed tag) is `validation_error`. Cloud answers a malformed `file_path` with 404 `blob_not_found` instead (see `file_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'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'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. Use the `hash` an upload returned: for an image whose location metadata was removed before storage, the client''s digest of the file it sent names no stored blob. A refusal answers its status with no body, so a `403`''s code is not readable here.'
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'
'403':
$ref: '#/components/responses/Forbidden'
'429':
$ref: '#/components/responses/RateLimited'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'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'
'429':
$ref: '#/components/responses/RateLimited'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'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'
'429':
$ref: '#/components/responses/RateLimited'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'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.
'429':
$ref: '#/components/responses/RateLimited'
'451':
description: '`content_blocked` on a deployment: content moderation flagged these bytes, so they are not served.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'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.
preview_method:
type: string
description: ComfyUI's preview method for this prompt, passed to ComfyUI as sent, which validates it. Used only for jobs sent by Comfy Cloud's editor; ignored for other callers.
extra_pnginfo:
type: object
description: ComfyUI's `extra_pnginfo` for this prompt, which carries the editor's workflow into saved outputs. Used only for jobs sent by Comfy Cloud's editor; ignored for other callers.
additionalProperties: true
metadata:
type: object
description: 'Your own labels for the job, such as which of your customers it ran for. At most 16 pairs. A key is 1 to 40 characters from `A-Z a-z 0-9 _ - .`; a value is a string of at most 256 bytes in UTF-8 (bytes, not characters: 200 `é` is 400 bytes) with no control character (U+0000 to U+001F, tab and newline included, and U+007F to U+009F) and no bidirectional embedding, override or isolate (U+202A to U+202E, U+2066 to U+2069). Anything else is refused `422` `metadata_invalid` and no job is created: a bad key or value is named in the message and in `details.key`, while more than 16 pairs is refused with the count and no `details`. A `metadata` that is not an object is refused `400` `invalid_request`; `null` is the same as none. Stored with the job, returned on it, and filterable on `GET /api/v2/jobs`. Set once, here: nothing changes it later. Only jobs sent to a deployment keep it today: Comfy Cloud refuses a non-empty `metadata` with `422` `metadata_not_supported`, and runs a job with empty or absent `metadata` as before.'
maxProperties: 16
additionalProperties:
type: string
description: 'At most 256 bytes in UTF-8, which is what the server counts. `maxLength` counts characters, so it is only an upper bound: a shorter value with multi-byte characters can still be refused.'
maxLength: 256
x-go-type: interface{}
example:
client: acme
batch: '2026-10-05'
responses:
'201':
description: Job created and queued.
content:
application/json:
schema:
$ref: '#/components/schemas/Job'
'400':
description: '`invalid_request`: `metadata` is not an object. A body that is not JSON at all, or whose `workflow` or `extra_data` is not an object, is a `400` too, answered by the request decoder before any of these checks, with a bare `message` body rather than this envelope.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'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'
'404':
description: '`not_found`: on a deployment''s address, no deployment there that the caller''s workspace owns.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: '`invalid_workflow` (with per-node details), `workflow_format_ui`, `missing_asset`, `idempotency_key_reuse`, `metadata_invalid` (for a bad key or value, the message and `details.key` name the key; for more than 16 pairs, the message gives the count and there are no `details`), or `metadata_not_supported` (a surface that keeps no job labels yet).'
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) or `deployment_unavailable` (the deployment is ready but its GPU provider is not taking work on it yet), or `rate_limited` (the caller is past a request rate limit). Disambiguate by `error.code`; all four mean back off and retry after `Retry-After`.'
headers:
Retry-After:
$ref: '#/components/headers/RetryAfter'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'500':
$ref: '#/components/responses/UpstreamError'
get:
operationId: listJobs
tags:
- jobs
summary: List jobs, newest first
description: 'Lists jobs newest first. At a deployment''s address, the jobs sent to
that deployment; at the workspace address, every job in the
caller''s workspace. A caller never sees another workspace''s jobs.
Filter by your own labels with `metadata[<key>]=<value>`, up to 3
times; a job is listed only when every pair matches exactly. A 4th
filter, a repeated key, a key outside the key rule, or a value with a
character no label may hold (a control character or a bidirectional
embedding, override or isolate, as on submit) is refused `400`
`invalid_metadata_filter`. A filter value must be URL-encoded; a raw
`;` or a bad `%` escape answers `400` `invalid_metadata_filter`.
Pages: pass the response''s `next_cursor` back as `cursor`, with the
same filters, to read the next page. `next_cursor` is absent on the
last page. Each job appears once across the pages.
List items are a job''s stored record, not the `Job` object: they
carry no `outputs` and no `urls`. Read a job with
`GET /api/v2/jobs/{id}` for its outputs.
Only the serverless platform lists jobs today. Comfy Cloud answers
`501` `not_implemented`.
'
parameters:
- name: limit
in: query
required: false
description: 'Most jobs per page. Above 500 is read as 500; absent means 500. Not a positive integer: `422` `invalid_request`.'
schema:
type: integer
minimum: 1
- name: cursor
in: query
required: false
description: 'The previous page''s `next_cursor`, unchanged. Opaque: do not build or edit one. A cursor that is not well-formed is refused `400` `invalid_cursor`. Cursors are not signed, so a well-formed one is read as a position whether or not this list issued it.'
schema:
type: string
- name: metadata
in: query
required: false
style: deepObject
explode: true
description: Exact-match label filters, written `metadata[client]=acme`. Up to 3; all must match.
schema:
type: object
maxProperties: 3
additionalProperties:
type: string
example:
client: acme
responses:
'200':
description: One page of jobs, newest first.
content:
application/json:
schema:
$ref: '#/components/schemas/JobList'
'400':
description: '`invalid_metadata_filter` or `invalid_cursor`.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: '`not_found`: on a deployment''s address, no deployment there that the caller''s workspace owns.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: '`invalid_request`: `limit` is not a positive integer.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'429':
$ref: '#/components/responses/RateLimited'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'500':
$ref: '#/components/responses/UpstreamError'
'501':
description: '`not_implemented`: this surface does not list jobs yet.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
/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'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'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'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'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, or
capture was attempted and failed.
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'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'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 either the terminal `status` event
or a terminal `error` event (see the `error` event below).
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'
error:
description: 'Terminal event sent when the stream ends for a reason OTHER than the job reaching a terminal state — the credential the stream was opened with stopped being accepted (`credential_expired`, the common case for a short-lived Cloud JWT or OAuth access token that expires while a long job is still running), or the job stopped being accessible (`job_not_found`, `forbidden`). Transient upstream failures (5xx, network errors) do NOT end the stream — they are retried on the next poll. No `status` event follows it. Its whole purpose is to make an aborted stream distinguishable from a completed one: without it both simply close, and a client cannot tell "your job finished" from "you were cut off". On `credential_expired` the job itself is unaffected — refresh the credential and reopen the stream, or fall back to `GET /api/v2/jobs/{id}`. A serverless deployment sends only `job_not_found`.
Browser note: a browser `EventSource` also fires a BUILT-IN `error` event on any transport failure, and a server-sent `event: error` frame is delivered to the same listener. The two are told apart by the payload — this event always carries a JSON `ErrorEnvelope` in `data`, the built-in one carries none — so a handler should check for `data` before treating an `error` as an explained, terminal end-of-stream rather than a retryable connection drop.'
schema: '#/components/schemas/ErrorEnvelope'
parameters:
- $ref: '#/components/parameters/JobId'
responses:
'200':
description: 'SSE stream; see `x-sse-events` for the event catalog. Sent with `Cache-Control: no-cache, no-store, no-transform` and `X-Accel-Buffering: no` so intermediaries neither cache, compress nor buffer the stream.'
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. Or `rate_limited`, the caller past a request rate limit; retry after `Retry-After`.'
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'
'503':
$ref: '#/components/responses/DeploymentLookupUnavailable'
'500':
$ref: '#/components/responses/UpstreamError'
You can’t perform that action at this time.
