Skip to content

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 same scopes an API key holds. An endpoint receives only events its scopes cover — an endpoint with assets:read never receives a transition event, exactly as a key with assets:read cannot 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.

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.

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. Walk the register with updated_since periodically 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 data rather than inferring state from the sequence.