Events and webhooks
The events
| Event | When | data |
|---|---|---|
job.started | The batch left draft (start, or continue after a stop). | status, counts, downloadUrl, reviewUrl, continueUrl |
job.paused | Every queued photo finished but some are still held (sample run, stop). | |
job.done | The batch finished. Download now. | |
item.done | A photo finished clean. | itemId, name, status, review, score, credits, attempts, error, outputUrl, thumbUrl, inputThumbUrl |
item.needs_review | A photo finished but the automatic check flagged it (not charged). | |
item.failed | The model could not process a photo. |
Every event is one JSON object:
{ "id": 7, "event": "item.needs_review", "at": 1757865600123, "jobId": "…",
"data": { "itemId": "…", "name": "IMG_0418.jpg", "status": "done", "review": "red", "score": 31, "credits": 1,
"attempts": 1, "outputUrl": "/v1/jobs/…/items/…/file?v=1", "thumbUrl": "…", "inputThumbUrl": "…" } }id counts up per batch; use it to notice a gap.
The stream
GET/v1/jobs/{id}/events
Server-Sent Events. Open it before or after starting; the first message is a snapshot (the batch without photos), then one message per event. It ends after job.done, after ten minutes (reconnect for a fresh snapshot), or when you close it.
curl -N https://trycutstack.com/v1/jobs/$JOB/events -H "Authorization: Bearer $CUTSTACK_API_KEY"
event: snapshot
data: {"id":"…","status":"queued","counts":{…},…}
id: 1
event: job.started
data: {"id":1,"event":"job.started","at":…,"jobId":"…","data":{…}}
id: 2
event: item.done
data: {…}
: ping 1757865615000Any SSE client works; in a browser it would be EventSource, but the key must not live in a browser, so use a server-side client.
Webhooks
Give the batch a URL when you create it, and every event is POSTed there:
curl -X POST https://trycutstack.com/v1/jobs \
-H "Authorization: Bearer $CUTSTACK_API_KEY" -H "Content-Type: application/json" \
-d '{ "defaults": { "presetId": "amazon-main" },
"webhookUrl": "https://shop.example.com/hooks/cutstack",
"webhookEvents": ["item.needs_review", "job.done"] }'{ "id": "…", "status": "draft", …,
"webhookUrl": "https://shop.example.com/hooks/cutstack",
"webhookSecret": "whsec_…",
"webhookEvents": ["item.needs_review", "job.done"] }webhookUrlmust be https on a public host.webhookEventsfilters; leave it out for all six.webhookSecretis shown only in this answer. Store it with the batch id: you need it to check signatures.
What arrives
POST /hooks/cutstack HTTP/1.1
Content-Type: application/json
User-Agent: Cutstack-Webhooks/1.0
X-Cutstack-Event: job.done
X-Cutstack-Delivery: 4f1c… (stable across retries)
X-Cutstack-Attempt: 1
X-Cutstack-Signature: t=1757865600,v1=8b3e…
{"id":9,"event":"job.done","at":1757865600123,"jobId":"…","data":{"status":"done","counts":{…},"downloadUrl":"/v1/jobs/…/download",…}}Answer any 2xx within 10 seconds. Anything else, or no answer, is a failed attempt.
Checking the signature
v1 is HMAC-SHA256 of `${t}.${rawBody}` with the batch’s secret. Compute it over the raw body bytes, before parsing, and refuse timestamps older than a few minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyCutstack(rawBody, signatureHeader, secret, toleranceSec = 300) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signatureHeader ?? "");
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}import hmac, hashlib, time
def verify_cutstack(raw_body: bytes, signature: str, secret: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in signature.split(","))
t, v1 = parts.get("t"), parts.get("v1", "")
if not t or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Retries
Five attempts: at once, then after 30 seconds, 2 minutes, 10 minutes and 30 minutes. After the fifth failure the delivery is given up and kept with its last status and error. Deliveries of one batch may arrive out of order under retries; use id to sort and X-Cutstack-Delivery to deduplicate.
GET/v1/jobs/{id}/webhooks
{ "jobId": "…", "webhookUrl": "https://shop.example.com/hooks/cutstack", "events": ["item.needs_review", "job.done"], "maxAttempts": 5,
"deliveries": [
{ "id": "4f1c…", "event": "job.done", "status": "delivered", "attempts": 2, "lastStatus": 200, "lastError": null, "deliveredAt": …, "payload": { … } },
{ "id": "9a02…", "event": "item.needs_review", "status": "given_up", "attempts": 5, "lastStatus": 500,
"lastError": "The endpoint answered 500 (gave up after 5 attempts)", "givenUpAt": …, "payload": { … } }
] }The body that was sent is included, so a lost event can be replayed by hand.
Webhooks tell you that something happened; the batch answer is the truth about what. On job.done, read the batch or the review queue and download; do not reconstruct state from events alone.
Prefer pulling? GET /v1/jobs/{id}?wait=60 on Batches needs no endpoint of yours.
Next: Errors. problem+json, every code, what to do.