Webhooks
Polling tells you what a register looks like now. A webhook tells you when something happened — a laptop assigned, a purchase request decided, a leaver's checklist opened — without asking every few minutes.
Registering an endpoint
In the Management Console, under API keys, add a webhook endpoint. You give it three things:
- A URL. It must be
https, on port 443, and reachable from the public internet. A URL on a private network is refused when you register it, not silently later. - Scopes. The read scopes an API key can hold; a write scope such as
kpi:recordcannot be given to an endpoint. An endpoint receives only events its scopes cover — an endpoint withassets:readnever receives a transition event, exactly as a key withassets:readcannot read one. - Event types. What you want to hear about. Subscribing to an event your scopes do not cover is refused, because an endpoint that can never receive what it subscribed to looks configured and does nothing.
You are shown a signing secret once. Store it before closing the dialog; nothing can show it to you again.
Verification
A new endpoint receives nothing. kitset sends one signed challenge — an endpoint.verification event — and the endpoint becomes active by answering it with any 2xx.
This exists so kitset cannot be pointed at somebody else's server. Without it, anybody who can sign up could register a URL they do not control and have us deliver retrying traffic to it under our own name.
What arrives
POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-Kitset-Event: asset.assigned
X-Kitset-Delivery: 7c2f...
X-Kitset-Signature: t=1758326400,v1=5257a869e7...
{
"id": "evt_9f21c0a4",
"type": "asset.assigned",
"created": "2026-09-20T09:14:02+00:00",
"api_version": "1",
"data": {
"asset": {"id": "a3f1", "asset_tag": "LAP-014", "status": "ASSIGNED"},
"assigned_user_id": "u8812",
"expected_return_at": null
}
}
People are ids, never embedded records — for the same reason the read API does it. If you need names, read /api/v1/people with people:read.
Events
Each event is sent once, at the moment below, to every active endpoint that subscribed to it and holds its scope. Its data carries only fields the matching /api/v1 resource already returns.
| Event | Scope required | Sent |
|---|---|---|
asset.assigned | assets:read | When an asset is checked out to a person. |
asset.returned | assets:read | When a checked-out asset is returned in the Assets register. Assets returned by completing an offboarding are reported by transition.completed instead. |
vendor.renewal_approaching | vendors:read | Once per renewal date, when the vendor's cancellation deadline (renewal date minus its notice period) first comes within 30 days. Checked every fifteen minutes. |
purchase_request.decided | approvals:read | When a request is finally approved or rejected. Not when it is sent back for changes, withdrawn, or passed from one approver to the next. |
handover.acknowledged | handovers:read | When the recipient of a holiday, shift or role handover acknowledges it. |
transition.started | transitions:read | When an onboarding or offboarding is submitted out of draft and its tasks go out. |
transition.completed | transitions:read | When a transition is marked complete; for an offboarding, after its assets are returned, its access removed and the account deactivated. Acknowledging a transition sends nothing. |
learning.acknowledged | learning:read | When an assignment is completed: a policy acknowledged, a course passed, or a completion recorded on somebody's behalf. |
Verifying the signature
Do this before you parse the body, and on the raw bytes. A signature checked after parsing is a signature checked against something you have already trusted.
import hashlib
import hmac
import time
TOLERANCE = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = {}
for chunk in header.split(","):
if "=" not in chunk:
continue
key, value = chunk.split("=", 1)
parts.setdefault(key.strip(), []).append(value.strip())
try:
timestamp = int(parts["t"][0])
candidates = parts["v1"]
except (KeyError, ValueError, IndexError):
return False
if abs(time.time() - timestamp) > TOLERANCE:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, candidate) for candidate in candidates)
Three details that are easy to get wrong:
- Sign over
f"{t}."then the raw body. The timestamp is part of what is signed, which is what stops a captured request being replayed with a new one. - Check the timestamp. Without it the signature is valid forever and a captured delivery can be replayed at any time.
- Read every
v1. The header may carry more than one during a secret rotation, and a receiver that reads only the first will reject half the deliveries.
Compare with a constant-time function — hmac.compare_digest, crypto.timingSafeEqual — not ==.
Retries, and what to do about them
Any response outside 2xx is a failure, and so is a timeout, a redirect or a TLS error. kitset retries with a growing delay: four attempts inside nine minutes, twelve in total, spanning about 21 hours.
Delivery is at-least-once. A slow acknowledgement means you will see the same event again, so treat id as an idempotency key and make handling an event twice harmless.
Answer quickly. Acknowledge with a 2xx as soon as you have the event somewhere durable, and do the work afterwards. A handler that finishes its own processing before responding is a handler that eventually times out.
Redirects are not followed. A 3xx is a failure. Register the final URL.
An endpoint that fails 20 deliveries in a row is switched off, and you will see it as disabled in the console with the reason. Nothing is lost: fix the endpoint, re-verify, and switch it back on.
Disabling, and why nothing deletes
You can disable an endpoint at any time. It stops receiving immediately, frees its slot — a workspace may have five endpoints delivering at once — and keeps its delivery history.
There is no delete, and that is deliberate. The delivery log is the only record of what left your workspace, and it is the first thing anybody needs after registering a URL that turned out to be wrong. Removing the endpoint would remove the evidence with it, so a disabled endpoint stays in the list, greyed out, with its deliveries still readable.
Rotating the signing secret
Rotating issues a new secret and shows it once. The new secret takes effect immediately, so deploy it to your receiver in the same change.
When a webhook is the wrong tool
- Reconciling. Webhooks tell you what changed; they do not tell you what you missed while your receiver was down. A correction made directly in the database sends no event at all, though it does move
updated_at. Walk the register withupdated_sinceperiodically as well. - Deletions. A removed record produces no event. If your mirror must notice removals, reconcile ids on a full walk.
- Ordering. Events are not ordered. Two events about one record can arrive out of order, so use the record in
datarather than inferring state from the sequence.