View Markdown

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_secret issues 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}/attempts lists each delivery attempt and its outcome.
  • POST /v2/webhook_endpoints/{endpoint_id}/replay sends 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.