Realtime & webhooks
You don't have to keep asking "is it done yet?" The moment a book's status changes, Kenly can push the new status to you, so your integration reacts instantly instead of polling on a timer. There are two ways to receive those pushes — pick the one that fits how your code runs:
| Transport | Best for | Delivery |
|---|---|---|
| SignalR status hub | a logged-in app / browser (the SPA) | a live WebSocket you subscribe to |
| Signed webhooks | headless agents, servers, MCP clients | an HMAC-signed POST to a URL you register |
Both feed off the same durable event log — an append-only record of every change — which is what makes them reliable. If a live message ever goes missing, you can always get it back: a hub client calls CatchUp, and a webhook receiver reads the events feed. And push can never disagree with poll, because the status object that's pushed is byte-for-byte the same one GET /v1/projects/{id}/status returns.
The envelope — ProjectStatusChange
Whichever transport you use, every message has the same shape: a ProjectStatusChange. It bundles two things — the durable event that just fired, and the freshly composed status of the book at that moment.
json
{
"event": {
"eventId": 128,
"projectId": "8f2c9d41-0b7a-4e2f-9c11-2a6b5d0e1f34",
"ownerId": "3a91c0de-77bb-4c22-8a10-9f2e1b3c4d55",
"event": "autoAdvance",
"fromState": "Processing",
"toState": "Complete",
"waitingOn": null,
"blockedOn": null,
"reason": null,
"correlationId": "0HN7…",
"occurredUtc": "2026-07-12T18:04:22.117Z"
},
"status": {
"projectId": "8f2c9d41-0b7a-4e2f-9c11-2a6b5d0e1f34",
"state": "Complete",
"phase": "done",
"title": "Translation complete",
"detail": "All 970 pages translated.",
"isWaiting": false,
"isBlocked": false,
"waitingOn": null,
"blockedOn": null,
"nextAction": "download",
"resumesAutomatically": false,
"progress": { "pagesTranslated": 970, "pagesTotal": 970, "ladderRung": "rest" },
"activeJob": null,
"quote": null,
"backend": { "state": "ready", "detail": "GPU ready." },
"wait": null,
"updatedUtc": "2026-07-12T18:04:22.140Z"
}
}A few things to know about the fields:
event.eventIdis a number that only ever counts up, assigned by the server. It's your replay cursor: remember the highest one you've handled, and to recover from any gap you just ask for everything after it.- All field names are camelCase, and every enum value is spelled out as a string, never a number —
"Complete","working","add_credit". The full list of possible values is in the enum reference. - The two halves are objects you'll see elsewhere too:
event(aProjectLifecycleEvent) is exactly what the events replay feed returns, andstatus(aProjectStatusView) is exactly what the status poll returns. Both are documented on their operation pages (projectStatus,projectEvents).
Channel: SignalR status hub
This is the browser-friendly option — a persistent WebSocket connection that the SPA (and any logged-in app) subscribes to. SignalR is Microsoft's realtime library, and there's an official client you can use.
| URL | GET /v1/hubs/status (SignalR / WebSocket) |
| Auth | the same JWT as the REST API, carried on the handshake (see below) |
| Server → client | method statusChanged, argument = one ProjectStatusChange |
| Client → server | method CatchUp(projectId, afterEventId) → ProjectLifecycleEvent[] |
Connecting
The hub signs you in with your JWT (the interactive credential — see Authentication). There's a small wrinkle: a browser can't attach an Authorization header to a WebSocket handshake, so SignalR passes the token as an access_token query parameter instead, and the server reads it from there. You don't have to handle any of that yourself — with the official @microsoft/signalr client you just give it an accessTokenFactory and it does the rest:
javascript
import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr";
const connection = new HubConnectionBuilder()
.withUrl("https://api.getkenly.com/v1/hubs/status", {
accessTokenFactory: () => getJwt(), // your current JWT; re-read on each (re)connect
})
.withAutomaticReconnect()
.configureLogging(LogLevel.Warning)
.build();
// Server → client: fires on every transition for a book you own.
connection.on("statusChanged", ({ event, status }) => {
console.log(`#${event.eventId} ${status.projectId} → ${status.state} (${status.phase})`);
lastSeenEventId = Math.max(lastSeenEventId, event.eventId);
});
await connection.start();The per-owner group model
You don't subscribe to anything or choose a channel name — there's nothing to pick. The moment you connect, the server puts you in exactly one group: your own, derived from the uid in your token. Every status change for a book you own is fanned out to that group automatically. You can't listen to anyone else's books, because the group is decided by the server from your token and never from anything the client sends — so eavesdropping on another account is structurally impossible, not just discouraged. A connection the server can't identify is dropped rather than left listening.
Replay — CatchUp
Automatic reconnect gets your socket back, but anything that happened while you were disconnected isn't re-sent live. So right after a reconnect, call CatchUp with the last eventId you processed to fetch the gap — that way you never miss an important final state like Complete or PausedNeedsCredit:
javascript
connection.onreconnected(async () => {
const missed = await connection.invoke("CatchUp", projectId, lastSeenEventId);
for (const event of missed) { // ProjectLifecycleEvent[], oldest-first
applyEvent(event);
lastSeenEventId = Math.max(lastSeenEventId, event.eventId);
}
});CatchUp(projectId, afterEventId) returns your lifecycle events for that project whose eventId is strictly greater than afterEventId (pass 0 to get the whole history), up to 500 at a time. If you get a full 500 back, there may be more — call again with the last id you received. A project you don't own comes back as an empty list (no existence leak), never an error.
Channel: signed webhooks
This is the option for headless agents and servers. You register a URL, and Kenly sends an HTTP POST to it on every status change. Each POST is HMAC-signed — that's a cryptographic signature computed with a shared secret — so you can verify the request really came from us and wasn't tampered with in transit.
Manage subscriptions — /v1/webhooks
All four management calls are authenticated (a JWT or an API key) and scoped to your account. They also show up in the Webhooks section of the API reference with a Try-it console.
| Operation | Route | Notes |
|---|---|---|
| Register | POST /v1/webhooks | body { "url": "…", "description": "…" }; the signing secret is returned once |
| List | GET /v1/webhooks | your subscriptions, without secrets (only the secretPrefix) |
| Delete | DELETE /v1/webhooks/{id} | 204; a subscription you don't own is a 404 |
| Rotate secret | POST /v1/webhooks/{id}/rotate-secret | mints a fresh secret (returned once), stops signing with the old one immediately |
bash
curl -X POST https://api.getkenly.com/v1/webhooks \
-H "Authorization: Bearer $KENLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.example.com/kenly","description":"prod book-status"}'json
{
"id": "d4e5f6a7-1b2c-3d4e-5f60-718293a4b5c6",
"url": "https://hooks.example.com/kenly",
"secretPrefix": "kenly_whsec_1a2b",
"description": "prod book-status",
"createdUtc": "2026-07-12T18:00:00Z",
"secret": "kenly_whsec_1a2b…full-value-shown-exactly-once…"
}Store the secret now
The full secret (a kenly_whsec_… string) is returned exactly once, at registration and on rotate. It is never retrievable again — GET /v1/webhooks only ever shows the non-secret secretPrefix. Save it to your receiver's config immediately. Lost it? Rotate to mint a new one.
Registration rules (SSRF / HTTPS)
Since we'll POST to whatever URL you register, we guard registration so it can't be abused to make our servers probe an internal network (an attack known as SSRF — server-side request forgery). A URL is rejected with 400 (validation) unless it is:
- an absolute
http(s)URL (noftp:,file:,javascript:, …); https— required in production, so a signed payload is never sent in cleartext (a dev host may relax this);- a public host — never loopback (
localhost,127.0.0.0/8,::1), a private/RFC-1918 range (10/8,172.16/12,192.168/16, CGNAT100.64/10), link-local, the cloud metadata IP169.254.169.254,0.0.0.0, or an internal suffix like.internal/.local; - within the maximum URL length.
The delivery
Each delivery is an HTTP POST to your URL with:
- Body — one
ProjectStatusChangeasapplication/json. X-Kenly-Signature—t=<unixSeconds>,v1=<hex>: the HMAC-SHA256 (lower-hex) of"{t}.{body}"keyed by your subscription secret. Thetis signed too, so a captured request can't be replayed later.X-Kenly-Event-Id— the durableeventId, for idempotent receivers (dedupe on this).
Respond 2xx quickly to acknowledge. A non-2xx or a timeout is retried with exponential backoff; a delivery that exhausts its retries is dropped but recoverable from the events feed — so a receiver that was down catches up rather than losing events.
Verifying a signature
Always verify the signature before you trust a payload — that's the whole point of signing it. Recompute the HMAC over "{t}.{body}" using the raw request body bytes exactly as received (don't parse and re-serialize the JSON — re-encoding changes the bytes and breaks the signature), compare your result to the v1 value in constant time, and reject a timestamp that's too old.
javascript
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
/** @param rawBody the exact request body (string or Buffer), NOT re-serialized JSON. */
export function verifyKenlyWebhook(secret, signatureHeader, rawBody) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const t = Number(parts.t);
const provided = parts.v1;
if (!t || !provided) return false;
// 1) Reject a stale / replayed timestamp.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
// 2) Recompute HMAC-SHA256 over "{t}.{body}" and compare in constant time.
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`, "utf8")
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(provided);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_kenly_webhook(secret: str, signature_header: str, raw_body: bytes) -> bool:
"""raw_body is the exact request body bytes, NOT re-serialized JSON."""
try:
parts = dict(p.strip().split("=", 1) for p in signature_header.split(","))
t = int(parts["t"])
provided = parts["v1"]
except (KeyError, ValueError):
return False
# 1) Reject a stale / replayed timestamp.
if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
return False
# 2) Recompute HMAC-SHA256 over "{t}.{body}" and compare in constant time.
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, provided)Here's a full Express receiver that keeps the raw body and dedupes on the event id:
javascript
import express from "express";
const app = express();
app.post(
"/kenly/webhooks",
express.raw({ type: "application/json" }), // keep RAW bytes for verification
(req, res) => {
const signature = req.header("X-Kenly-Signature");
if (!signature || !verifyKenlyWebhook(process.env.KENLY_WEBHOOK_SECRET, signature, req.body)) {
return res.sendStatus(401);
}
const eventId = req.header("X-Kenly-Event-Id");
if (alreadyProcessed(eventId)) return res.sendStatus(200); // idempotent
const { event, status } = JSON.parse(req.body.toString("utf8"));
handleStatusChange(event, status);
markProcessed(eventId);
res.sendStatus(200); // ack fast — a non-2xx triggers a retry
},
);Rotating a secret
POST /v1/webhooks/{id}/rotate-secret mints a fresh secret, returns it once, and stops signing with the old one immediately — the standard credential-rotation move for compromise recovery or periodic hygiene. The URL and description are unchanged. Update your receiver's configured secret to the new value the same way you did at registration.
bash
curl -X POST https://api.getkenly.com/v1/webhooks/$WEBHOOK_ID/rotate-secret \
-H "Authorization: Bearer $KENLY_API_KEY"The events replay feed
This is the log that makes both channels reliable. Every transition is also written to a durable, append-only record you can read back any time — it's both the audit trail and the safety net when a live delivery goes missing.
bash
curl "https://api.getkenly.com/v1/projects/$PROJECT_ID/events?afterEventId=128&limit=100" \
-H "Authorization: Bearer $KENLY_API_KEY"json
{
"events": [ /* ProjectLifecycleEvent, oldest-first, eventId > afterEventId */ ],
"nextCursor": 231
}afterEventId(default0) is the cursor — pass the highesteventIdyou've processed to get only what's new.nextCursoris non-null when a full page came back (more may remain — call again withafterEventId=nextCursor); it isnullonce you're caught up.limitdefaults to 100, max 500.
This is the exact same log CatchUp reads from, so a hub client and a webhook receiver recover through one source of truth. See projectEvents in the reference.
Per-event-type reference
The event.event field tells you which transition fired. Every event uses the same envelope; what changes from one to the next is the fromState → toState it records and the status it composes.
event | Fires when | Typical toState |
|---|---|---|
create | a project is created (fromState is "None") | Created |
normalize | the source is normalized into ordered pages | Calibrating |
processBatch | a batch (calibration or a widened rung) is processed | Processing, Complete |
autoAdvance | the next batch is auto-enqueued, or the book settles | Processing, Complete, PausedNeedsCredit, PausedNeedsCapacity |
retry | a failed page/batch is retried | Processing |
cancel | the owner requests cancellation | Cancelling |
finalizeCancel | cancellation completes | Cancelled |
Example — waiting on GPU capacity
Here the book paused on its own and will resume on its own — resumesAutomatically is true, nextAction is null (there's nothing for the user to do), and the wait facet carries best-effort estimates that are never promised.
json
{
"event": {
"eventId": 74, "projectId": "8f2c…", "ownerId": "3a91…",
"event": "autoAdvance", "fromState": "Processing", "toState": "PausedNeedsCapacity",
"waitingOn": "gpu_capacity", "blockedOn": null, "reason": null,
"correlationId": "0HN7…", "occurredUtc": "2026-07-12T17:58:10.004Z"
},
"status": {
"projectId": "8f2c…", "state": "PausedNeedsCapacity", "phase": "waiting",
"title": "Waiting for a GPU", "detail": "Waiting for GPU capacity to free up.",
"isWaiting": true, "isBlocked": false,
"waitingOn": "gpu_capacity", "blockedOn": null, "nextAction": null,
"resumesAutomatically": true,
"progress": { "pagesTranslated": 30, "pagesTotal": 970, "ladderRung": "25" },
"activeJob": null, "quote": null,
"backend": { "state": "throttled", "detail": "GPU capacity is constrained." },
"wait": { "etaSeconds": null, "nextRetryUtc": "2026-07-12T18:03:10Z", "queuePosition": 2 },
"updatedUtc": "2026-07-12T17:58:10.020Z"
}
}Example — blocked on credit
Here the wallet is out of balance. This one is blocked on the user, not waiting (isBlocked is true, blockedOn is credit, waitingOn is null), so nextAction spells out exactly what to do — add_credit. Notice the deliberate combination: it's blocked on a user action, and yet resumesAutomatically is true — the moment the top-up lands, the run continues with no further call from you.
json
{
"event": {
"eventId": 91, "projectId": "8f2c…", "ownerId": "3a91…",
"event": "autoAdvance", "fromState": "Processing", "toState": "PausedNeedsCredit",
"waitingOn": null, "blockedOn": "credit", "reason": "Next batch would exceed the wallet balance.",
"correlationId": "0HN7…", "occurredUtc": "2026-07-12T18:01:44.512Z"
},
"status": {
"projectId": "8f2c…", "state": "PausedNeedsCredit", "phase": "waiting",
"title": "Add credit to continue", "detail": "The next batch would exceed your balance.",
"isWaiting": false, "isBlocked": true,
"waitingOn": null, "blockedOn": "credit", "nextAction": "add_credit",
"resumesAutomatically": true,
"progress": { "pagesTranslated": 130, "pagesTotal": 970, "ladderRung": "100" },
"activeJob": null,
"quote": { "expectedUsd": 0.84, "ceilingUsd": 1.20, "balanceUsd": 0.15 },
"backend": { "state": "ready", "detail": "GPU ready." },
"wait": null,
"updatedUtc": "2026-07-12T18:01:44.530Z"
}
}Enum reference
Each field below is a string on the wire, and the values match the status contract. Treat these as closed sets with an "unknown" fallback: switch on the values you know and degrade gracefully on any you don't, so a future added value can never break your client.
event.event — lifecycle event name
create · normalize · processBatch · retry · autoAdvance · cancel · finalizeCancel
state / fromState / toState — the state-machine value
Created · Calibrating · Processing · PausedNeedsCredit · PausedNeedsCapacity · Complete · Failed · Cancelling · Cancelled — plus None as the fromState of a create event.
phase — coarse bucket
intake · working · waiting · done · error · cancelled
waitingOn — why a waiting book is paused (else null)
gpu_capacity · gpu_warmup · queue · credit (reserved — an out-of-credit book is blocked, not waiting; kept only so an exhaustive switch stays valid)
blockedOn — what a blocked book needs resolved (else null)
credit · retry
nextAction — the single sensible next move (else null ⇒ wait / poll)
add_credit · retry · download
Source of truth. The push transports are single-sourced in the repo at
docs/api-contract.md§4c (real-time push) and the status taxonomy indocs/design/book-status-state-machine.md; this page renders them for developers. The message and enum tables track the shipped status contract — if the contract changes, regenerate the OpenAPI snapshot and update this page in step.