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¶
The webhook (recommended)¶
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:
- Sign over the raw body. Re-serialising the JSON changes the bytes and the signature will not match.
- Dedupe on
Nestor-Webhook-Id. Delivery is at-least-once; a slow 200 can still be retried. - Respond 2xx quickly. Slower than 15s counts as failure. Queue the work.
- Don't trust the payload's
statusalone 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:
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.