Skip to content

Integrating with Nestor Serve

A complete worked integration, end to end. The API reference lists the endpoints; this is the narrative, what your backend actually does, in order.

The console at /dashboard is read-only by design. Everything below happens from your own code. The console is for watching and debugging, never a place you'd be asked to click to get work done.


The shape of it

your backend                      Nestor                      your webhook
────────────                      ──────                      ────────────
POST /v1/endpoints/{ep}/jobs  ──▶  queued
   ◀── 202 {"id":"job_..."}         │
                                    ├─ a GPU claims it
                                    ├─ runs your workflow
                                    ├─ uploads artifacts to S3
                                    └─ POST job.succeeded  ──▶  verify signature
                                                                fetch artifact
                                                                update your DB

You submit and forget. The webhook tells you when it's done. Polling is a fallback, not the main path.


1. Get your workflow into API format

In ComfyUI: Settings → Enable Dev mode options, then Save (API Format).

That file is what goes in input.workflow. The normal "Save" format is a UI graph and will be rejected. This is the single most common first-run mistake.

Keep one API-format file per endpoint, in your repo, versioned. Treat them as code: they are the thing that determines your output.


2. Submit

import os, uuid, httpx

NESTOR = "https://api.serve.nestor.software"
KEY    = os.environ["NESTOR_API_KEY"]        # server-side only
EP     = os.environ["NESTOR_ENDPOINT"]       # ep_...

def submit(workflow: dict, our_id: str) -> str:
    r = httpx.post(
        f"{NESTOR}/v1/endpoints/{EP}/jobs",
        headers={
            "Authorization": f"Bearer {KEY}",
            "Idempotency-Key": our_id,       # see below
        },
        json={
            "input": {"workflow": workflow},
            "metadata": {"our_id": our_id, "batch": "nightly"},
            "webhook_url": "https://your.app/hooks/nestor",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["id"]                    # job_...

Idempotency-Key is how you retry safely. If the connection drops after we accepted the job but before you saw the response, retrying with the same key returns the original job (HTTP 200 instead of 202) rather than running the work twice. Use your own primary key for the request, not a random UUID per attempt.

metadata is echoed back everywhere, on reads and on every webhook. Put your own IDs there and you never need a job-ID mapping table.


3. Receive the result

import hashlib, hmac, os
from fastapi import FastAPI, Header, HTTPException, Request

SECRET = os.environ["NESTOR_WEBHOOK_SECRET"]
app = FastAPI()

@app.post("/hooks/nestor")
async def nestor_hook(
    request: Request,
    nestor_webhook_id: str = Header(...),
    nestor_webhook_timestamp: str = Header(...),
    nestor_webhook_signature: str = Header(...),
):
    raw = await request.body()               # RAW bytes, never the parsed dict
    msg = f"{nestor_webhook_id}.{nestor_webhook_timestamp}.".encode() + raw
    expected = "v1=" + hmac.new(SECRET.encode(), msg, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, nestor_webhook_signature):
        raise HTTPException(401, "bad signature")

    if already_processed(nestor_webhook_id):  # at-least-once: dedupe
        return {"ok": True}

    event = json.loads(raw)
    job = event["data"]["job"]
    enqueue_for_processing(job)               # do the work OFF this request
    mark_processed(nestor_webhook_id)
    return {"ok": True}                       # respond 2xx fast

Four rules, each of which bites if ignored:

  1. Sign over the raw body. Re-serialising the JSON changes the bytes and the signature will not match.
  2. Dedupe on Nestor-Webhook-Id. Delivery is at-least-once; a slow 200 can still be retried.
  3. Respond 2xx quickly. Slower than 15s counts as failure. Queue the work.
  4. Don't trust the payload's status alone if the action is destructive, GET /v1/jobs/{id} is authoritative.

If your endpoint is down, we retry 10 times over roughly two hours, then mark the delivery exhausted. The job still succeeded, so you can always poll.

Polling (fallback)

job = httpx.get(f"{NESTOR}/v1/jobs/{job_id}",
                headers={"Authorization": f"Bearer {KEY}"}).json()
if job["status"] in ("succeeded", "failed", "canceled"):
    ...

Poll no faster than every 2s per job. If you find yourself polling thousands of jobs, use GET /v1/endpoints/{ep}/jobs?status=succeeded and page instead.


4. Take the artifacts

for art in job["output"]["artifacts"]:
    # art: {type, url, content_type, file_name, file_size, width, height}
    data = httpx.get(art["url"], timeout=120).content
    put_in_our_bucket(art["file_name"], data, art["content_type"])

Artifact URLs expire after 7 days. They are presigned links to our storage, not permanent hosting. Mirror anything you intend to keep or serve to end users.

art["type"] is image, video, audio or file. A workflow that emits video or audio returns it the same way, so branch on the type rather than assuming images.


5. Handle failure properly

if job["status"] == "failed":
    code = job["error"]["code"]
    if code in ("worker_lost", "runtime_unreachable", "agent_error"):
        pass            # infrastructure, already retried for you; safe to resubmit
    elif code in ("workflow_rejected", "invalid_input", "no_outputs"):
        alert_us(job)   # your workflow is wrong; resubmitting will fail again
    elif code == "timeout":
        pass            # exceeded the wall-clock limit; split the job or raise the limit

The distinction that matters: infrastructure failures are retried for you (the job returns to the queue, and the ComfyUI driver derives the same seed), while job-level failures are not, retrying a bad workflow just fails twice.

job["attempt"] > 1 means it was retried after infrastructure trouble. The result is unaffected: any "seed": -1 was frozen to a concrete value when we accepted the job, so a retry reproduces the same image rather than a new one.


6. Manage concurrency

You have a fixed number of GPUs, so submitting faster than they drain just grows the queue.

st = httpx.get(f"{NESTOR}/v1/endpoints/{EP}/status",
               headers={"Authorization": f"Bearer {KEY}"}).json()

st["workers"]["ready"]            # concurrent jobs you can actually run
st["in_flight"]["queued"]         # waiting right now
st["oldest_queued_seconds"]       # how far behind we are

A reasonable rule: keep queued under roughly 2× workers.ready. Past that, apply backpressure at your end rather than letting the queue absorb it. A deep queue turns into user-visible latency with no signal to your service.

workers.ready excludes slots that have stopped heartbeating, so it is real capacity rather than configured capacity.


7. Know which image ran your job

Every job records the digest of the container image that produced it:

{ "id": "job_...", "image_digest": "sha256:f357e3e085643f47..." }

And GET /v1/endpoints/{ep}/workers shows what each node is running. This matters during a rollout: nodes are updated one at a time, so for a period some jobs run the old image and some the new. If output characteristics shift, the digest tells you which jobs ran which bytes.

Deploying a new image is self-serve:

curl -X POST $NESTOR/v1/endpoints/$EP/deployments \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"image":"yourrepo/comfyui:2026-08-11","notes":"adds upscaler"}'

Each GPU finishes its current job, then pulls and switches, no running job is interrupted. Expect the roll to take about the longest in-flight job plus the pull time. Poll GET /v1/endpoints/{ep}/deployments and watch workers_on_this / workers_total climb, or watch the Capacity tab.

Rolling back is activating an earlier revision:

curl -X POST $NESTOR/v1/endpoints/$EP/deployments/$DEP_ID/activate \
  -H "Authorization: Bearer $KEY"

Two things worth building into your release process: pin a digest (repo/img@sha256:...) rather than a mutable tag when you care about reproducibility, and check workers_on_this is still climbing. A failed pull leaves that worker on the old image rather than going dark, so a broken image degrades into a stuck rollout rather than an outage.


Limits and current gaps

Being explicit so nothing is a surprise:

Per-job wall clock 1 hour default, raisable per endpoint, ask
Artifact URL lifetime 7 days
Webhook retries 10 attempts over ~2 hours, 15s timeout each
Job listing page size 100 max
Concurrency equal to workers.ready, dedicated, not elastic

Not available yet, so you can plan around them: no autoscaling (capacity is the nodes you have, always warm, never scaled to zero), no job priority or per-endpoint job priority, no progress reporting between running and terminal, and no self-serve model upload, models come from your workflow's own download nodes or from us placing them on the nodes.