Last updated 2026-11-02
Webhooks and live progress
Two ways to know when work is finished without polling: a webhook, one signed POST to your server when a deck or a whole-deck edit is done or fails, and an event stream you hold open to watch a build as it happens.
Ask for a callback
Add callback_url to POST /v1/decks, or to a whole-deck edit (POST /v1/decks/{id}/edits without slide):
curl https://kinsy.ai/v1/decks \
-H "Authorization: Bearer $KINSY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"brief": "Q3 board update", "callback_url": "https://example.com/hooks/kinsy"}'
The URL must be https, on a public address, with no username or password in it; anything else is refused with 400 before you are charged. Callbacks need an API key or a signed-in session: an assistant you connected by signing in cannot set one (403), so nothing it does can send a genuinely signed request to a server of its choosing.
Get your signing secret
Every callback is signed with your account's secret. Read it once and keep it on your server:
curl https://kinsy.ai/v1/webhooks/secret -H "Authorization: Bearer $KINSY_API_KEY"
# {"secret": "whsec_…", "created_at": "2026-11-02T09:00:00+00:00", "rotated_at": null}
POST /v1/webhooks/secret/rotate replaces it; the old secret stops working at once. Both calls need an API key or a signed-in session. An assistant you connected by signing in cannot read or rotate the secret.
What arrives
POST /hooks/kinsy HTTP/1.1
Content-Type: application/json
User-Agent: Kinsy-Webhooks/1
X-Kinsy-Event: deck.done
X-Kinsy-Delivery: dlv_3f9a0c1d2e4b5a6978c0d1e2
X-Kinsy-Signature: t=1793610000,v1=5d41402abc4b2a76b9719d911017c592…
{"id": "dlv_3f9a0c1d2e4b5a6978c0d1e2", "type": "deck.done", "created_at": "2026-11-02T09:01:40+00:00",
"data": {"deck_id": "deck-4c9e01", "status": "done", "phase": "done", "progress": 1.0,
"slides_rendered": 8, "slides_total": 8, "title": "Q3 board update", "client": "api"}}
| Event | When |
|---|---|
deck.done | The deck is finished. Fetch the files with GET /v1/decks/{id}. |
deck.error | The build failed; data.error says why in one sentence. Failed builds are refunded. |
deck.edit.done | A whole-deck edit finished; data.edit.changed lists the slides it changed. |
deck.edit.error | A whole-deck edit failed and was refunded; data.edit.error says why. |
data is the same status GET /v1/decks/{id} returns, without download links: call that route with your key for fresh ones. Download links never travel in a callback, so they never end up in your request logs.
Verify the signature
The signature is HMAC-SHA256, keyed with your secret, over the timestamp, a dot, and the raw request body. Compare in constant time, and refuse a timestamp more than five minutes old so a captured request cannot be replayed.
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance_s: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = int(parts["t"]), parts["v1"]
if abs(time.time() - t) > tolerance_s:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
import crypto from "node:crypto";
export function verify(secret, header, rawBody, toleranceS = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Verify against the bytes you received, before any JSON parsing or re-serialising.
Delivery and retries
- Answer with any
2xxwithin five seconds; do the slow work after you reply. - A time-out, a connection failure,
408,429or a5xxis retried after 1, 5 and 25 seconds (four tries in all). Any other answer, including a redirect, is final: redirects are not followed. Five seconds is a limit on the whole exchange, not on each read. - The same event for the same deck always carries the same
X-Kinsy-Deliveryid, so you can safely ignore a repeat. - The outcome of the last callback shows on the deck's status as
webhook:event,delivered,attempts,status_codeandat.
Follow a build live
GET /v1/decks/{id}/events streams the build's progress as server-sent events. Use the api.kinsy.ai host for streams:
curl -N https://api.kinsy.ai/v1/decks/deck-4c9e01/events \
-H "Authorization: Bearer $KINSY_API_KEY"
id: 0
event: started
data: {"seq":0,"phase":"started","at":"2026-11-02T09:00:01.120+00:00"}
id: 3
event: plan_ready
data: {"seq":3,"phase":"plan_ready","at":"…","title":"Q3 board update","slides":[…]}
event: end
data: {"status":"done","reconnect":false}
idis the event's sequence number andeventits phase (started,plan_ready,slide_rendered,draft_ready,done,error, and a few in between).- The stream sends everything so far, then new events as they happen. It closes after
doneorerror, or after 55 seconds; the last frame,end, says whether to reconnect. - To carry on, reconnect with
Last-Event-ID: <last id>(most event-stream clients do this for you), or pass?since=<last id>. - A stream opened on a finished deck sends what you missed and closes at once.
- At most three streams can be open per account, and at most six opened a minute; beyond that you get
429. PollingGET /v1/decks/{id}every few seconds works just as well. - If a build's progress has not moved for fifteen minutes, the stream ends with
"status": "stalled"and does not ask you to reconnect; checkGET /v1/decks/{id}.
Whole-deck edits
POST /v1/decks/{id}/edits with an instruction and no slide applies the instruction to every slide in the background. It answers 202 with an edit_id; follow it on GET /v1/decks/{id}, whose edit block moves from queued to running to done (with changed, the slides it changed) or error. It costs 2 credits per slide, charged when it is queued and refunded in full if it fails. One whole-deck edit runs at a time per deck, and a deck still being built cannot be edited (409).