Webhooks
A webhook endpoint is a URL of yours that Kabu calls with every event you subscribe to: a message answered, an ask expired, a session ended. The events are the same objects the event stream delivers, signed per Standard Webhooks, so any of its libraries can verify them.
Register an endpoint
Endpoints are managed with a key holding webhooks:write (a service or admin key), or in the console:
curl https://usekabu.com/api/v2/webhook_endpoints \
-H "Authorization: Bearer $KABU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"url": "https://example.com/kabu/webhook", "events": ["message.resolved", "message.expired"]}'events lists the types to send, or ["*"] for all. The response includes the endpoint’s signing secret
(whsec_…) this once: store it. The URL must be public HTTPS; its address is checked again on every delivery, and
redirects are never followed. POST /v2/webhook_endpoints/{endpoint_id}/test sends a ping event to try it.
What arrives
POST /kabu/webhook HTTP/1.1
Content-Type: application/json
webhook-id: evt_01J9Z…
webhook-timestamp: 1793353260
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Kabu-Version: 2026-10-17
{"object": "event", "id": "evt_01J9Z…", "type": "message.resolved", "resource": "msg_…", "resource_version": 3, "data": {"object": {…}}}The body is an Event: its type, the resource it is about and that resource’s
resource_version, and data.object, the resource as it is now. Event types:
| Type | When |
|---|---|
message.created |
A message was sent |
message.delivered |
It reached a device |
message.resolved |
An ask was answered |
message.expired |
An ask expired unanswered |
message.cancelled |
An ask was cancelled |
session.created, session.updated, session.ended |
A session started, changed or ended |
agent.paused |
An agent sent too many near-identical messages and was paused |
ping |
A test, from …/test |
New types may appear; ignore the ones you do not know.
Verify the signature
The signature is HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, keyed with your secret’s bytes (the
base64 after whsec_), sent base64-encoded after v1,. While a rotated secret is still valid, the header carries two
signatures separated by a space; accept the delivery if either matches. Check the timestamp is recent too, so an old
delivery cannot be replayed at you:
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, body: bytes) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
fresh = abs(time.time() - int(headers["webhook-timestamp"])) < 300
return fresh and any(
hmac.compare_digest(expected, sig.removeprefix("v1,")) for sig in headers["webhook-signature"].split()
)Verify the raw body, before parsing it: re-encoded JSON does not match.
Answer fast, then work
Answer any 2xx within 10 seconds and do the work afterwards. Anything else is retried: first after 5 seconds, then
twice as long each time, up to an hour apart, for 3 days. Delivery is at least once and may repeat an event, so apply an
event only when its resource_version is newer than the one you hold.
Rotate, inspect, replay
POST /v2/webhook_endpoints/{endpoint_id}/rotate_secretissues a new secret; the old one keeps signing alongside it for a while, so you can switch without missing a delivery.GET /v2/webhook_endpoints/{endpoint_id}/attemptslists each delivery attempt and its outcome.POST /v2/webhook_endpoints/{endpoint_id}/replaysends the events after a checkpoint again, up to 7 days and 100,000 events back.
The Webhooks reference has every operation, the endpoint’s fields and the delivery itself.