Automating ComfyUI Jobs: WebSocket Events, /history Polling, and Why There Is No Native Webhook

If you are wiring ComfyUI into an automation pipeline, the first thing most people reach for is a webhook: submit the prompt, give it a callback URL, and get notified when the image is ready. That assumption is the source of a lot of wasted debugging, because ComfyUI does not expose a webhook endpoint. There is no POST /prompt callback URL, no notify_url field, and no built-in outbound HTTP hook in the current server.

This guide is based on reading the ComfyUI server and execution source directly, not on a particular UI version. The automation path that actually works has three reliable pieces: the WebSocket event stream at /ws, the per-prompt history endpoint at /history/{prompt_id}, and the queue state endpoint at /queue. Understand all three and you can build notifications that are at least as reliable as a webhook, without waiting for one to be added.

Why There Is No Webhook (And What To Use Instead)

ComfyUI’s HTTP server (server.py) exposes a fixed set of routes. The prompt submission route is POST /prompt, and it returns immediately with a prompt_id plus a number (the queue position). It does not accept a callback URL, and nothing in the request body causes the server to make an outbound request later. Job completion is communicated only two ways: over the in-band WebSocket channel, or by polling the history endpoints.

This is a deliberate design. A webhook would require ComfyUI to hold outbound network state, handle retries, and trust caller-supplied URLs — all friction for a local-first tool where the client is usually on the same machine. The result is that you own the notification. The good news: everything you need to build one is already there.

Note that the newer /api/jobs endpoints (visible in the route table) expose job objects with lifecycle state, but for image-generation automation the three endpoints above remain the practical, stable surface. Different ComfyUI frontends and third-party runners agree on them precisely because they are the lowest common denominator.

The WebSocket Stream: The Event-Based Replacement for a Webhook

The closest thing to a push-based callback is the WebSocket at GET /ws. It accepts an optional clientId query parameter; if you omit it, the server assigns one and returns it in the initial status message. Reuse that clientId across reconnects so the server keeps you bound to the same logical session.

When you submit a prompt, you can pass extra_data.client_id in the JSON body. The server folds that value into the queued item, and the execution engine uses the socket bound to that id to stream progress. The message types you care about are:

  • executing — sent as each node starts, with the node id
  • executed — sent per node as it finishes, including the node’s output
  • execution_success — sent once when the whole prompt finishes, carrying the prompt_id
  • execution_error / execution_interrupted — the failure signals
  • progress — per-step sampler progress when a progress handler is active

A minimal polling-free listener looks like this (Python, using the websockets library):

import json, asyncio, websockets

async def listen(client_id):
    uri = f"ws://127.0.0.1:8188/ws?clientId={client_id}"
    async with websockets.connect(uri) as ws:
        while True:
            msg = json.loads(await ws.recv())
            if msg["type"] == "execution_success":
                print("DONE", msg["data"]["prompt_id"])
                return
            if msg["type"] in ("execution_error", "execution_interrupted"):
                print("FAILED", msg)
                return

The key operational detail: the event is tied to your client_id. If you open the socket without passing the id you submitted with, or if you submit without extra_data.client_id and listen on a fresh socket, you will not reliably receive the completion signal. Align the two ids and this behaves like a push notification.

Polling /history/{prompt_id}: The Durable, Restart-Safe Fallback

WebSocket messages are ephemeral. If your listener process dies, sleeps, or reconnects at the wrong moment, the execution_success frame is gone and you have no record. For long-running or unattended pipelines, poll GET /history/{prompt_id} instead. It returns a durable record keyed by prompt id:

{
  "1234": {
    "prompt": [...],
    "outputs": {
      "9": {"images": [{"filename": "ComfyUI_00042_.png", ...}]}
    },
    "status": {"status_str": "success", "completed": true}
  }
}

Poll it on an interval (a few seconds is plenty for image generation), and treat status.status_str as the source of truth: success means the outputs in that same object are final. This pattern survives a client restart, a network blip, and even a ComfyUI restart (history is kept in memory, so a full server restart clears it — see the caveats below).

Inside the output object, the images array gives you filenames under the ComfyUI output directory. You can fetch them over GET /view?filename=... or read them straight off disk when running locally.

Reading /queue To Decide When To Submit

Blindly submitting prompts is how you end up with a backlog. GET /queue returns queue_running and queue_pending, so you can gate submissions:

import requests
q = requests.get("http://127.0.0.1:8188/queue").json()
ahead = len(q["queue_running"]) + len(q["queue_pending"])
print(f"{ahead} items ahead of a new submission")

This matters most when multiple workers or a web frontend share one ComfyUI instance. queue_running is a single item (the currently executing prompt), while queue_pending is the waiting list. If a job is already in flight, your new prompt will simply queue behind it — the number returned by POST /prompt tells you exactly how far back you are.

Common Pitfalls When Automating

Several failures come up repeatedly and are almost never ComfyUI bugs:

  • client_id mismatch. The WebSocket received execution_success for nothing because the listener’s id differs from the submitter’s id. Pass extra_data.client_id explicitly, and reuse it.
  • Assuming JSON output is a URL. History gives you a filename, not a public URL. You must resolve it via /view or the filesystem; a webhook-style consumer that expects a URL will silently break.
  • Reliance on in-memory history. /history is not persisted to disk. Restart the ComfyUI process and prior records vanish. If you need durable job tracking, persist prompt ids and outputs yourself.
  • Ignoring execution_cached. When a node’s inputs are unchanged, ComfyUI may return cached outputs and emit execution_cached rather than re-running it. Your listener should treat cached completion the same as fresh completion or you will stall waiting for an executed frame that never comes.
  • Polling too aggressively. History-polling every 100 ms just adds load to a local server. A 2–5 second interval is enough and far friendlier to the aiohttp event loop.

Verifying Your Automation End To End

Build a tiny harness that strings the three pieces together and confirm each layer independently before trusting a big pipeline:

  1. Submit a trivial no-op or tiny workflow and capture the returned prompt_id.
  2. Queue check: confirm /queue shows queue_running populated while it executes.
  3. History check: poll /history/{prompt_id} until status.status_str == "success", then read the output filenames.
  4. Socket check: with the same client_id, capture execution_success and confirm it carries the matching prompt_id.

Only after all four pass should you scale up to real workflows. The WebSocket path is what you want for low-latency, interactive-looking notifications; the history-polling path is what you want for unattended batch jobs that must survive a listener restart. Used together — WebSocket as the fast signal, history as the durable record — they cover everything a webhook would, without depending on a feature that does not exist.

Conclusion

Stop looking for a ComfyUI webhook: there is not one, and the maintainers’ local-first design makes one unlikely to appear in the form people expect. Instead, treat the WebSocket /ws event stream as your push channel, /history/{prompt_id} as your durable source of truth, and /queue as your backpressure gauge. Align the client_id across submit and listen, resolve filenames yourself, and persist prompt ids if you need records to survive a restart. That combination gives you reliable, auditable automation on top of the API ComfyUI actually ships.

For the broader API automation harness that handles submission, history, and output download in one place, see our guide on building a reliable Python harness for ComfyUI. If you are running video models on a local GPU and want the resource picture before automating, the Wan2.2 Animate 14B BlockSwap walkthrough walks through VRAM constraints that affect queue timing. For the authoritative API surface, refer to the official ComfyUI endpoints documentation and the ComfyUI server source, which is the ground truth when UI behavior and documentation disagree.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *