Webhooks
Webhooks tell your systems what happens in your conversations as it happens: a handoff your team should pick up, a reply to deliver over another channel, a new ticket, a usage alert. K-Agent signs every delivery with Standard Webhooks, so you can prove it came from us.
1. Create an endpoint
Section titled “1. Create an endpoint”curl https://api.k-agent.kerneltics.com/v1/webhook_endpoints \ -H "Authorization: Bearer $KAGENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/kagent", "description": "Support backend", "events": ["handoff.requested", "handoff.resolved", "message.created", "ticket.created"] }'The response includes the signing secret (whsec_…) once — store it like a password. Rotate it with POST /v1/webhook_endpoints/{whep}/rotate_secret, which also shows the new secret once.
eventslists the event types to receive, or["*"](the default) for all of them. A type that isn’t in the catalog below returns422 unknown_event_type.- The URL must be public
https://on port 443 or 8443; private and internal addresses are refused, and redirects are not followed. - A project can have up to 10 endpoints.
2. Events
Section titled “2. Events”| Event | Sent when |
|---|---|
message.created |
A user, assistant or public human_agent message is stored. Internal notes are never included. |
note.created |
Your team adds an internal note. |
run.completed, run.failed, run.requires_action |
A run finishes, fails or waits for client tool outputs. |
session.created, session.updated, session.closed |
A session starts, changes mode or status, or closes. |
handoff.requested, handoff.assigned, handoff.unclaimed, handoff.resolved, handoff.expired |
The handoff lifecycle. |
ticket.created |
The agent (or your code) opened a ticket. |
conversation.flagged |
The agent flagged a conversation for review. |
usage.threshold_reached |
Usage reached 75% or 100% of the plan ({percent}). |
knowledge_source.sync_failed |
An API knowledge source failed to refresh. |
webhook_endpoint.disabled |
An endpoint was disabled after failing for 5 days. |
Events from the dashboard’s test panel are never sent.
Payload
Section titled “Payload”Every delivery is a POST with a JSON body:
{ "type": "handoff.requested", "id": "evt_01k6rz8e0h2k4n6q8s0v2x4z6b", "created_at": 1791272100, "data": { "handoff": { "id": "ho_01k6rz6c8e0g2j4m6p8r0t2v4x", "object": "handoff", "session_id": "sess_01k6rz4p7h2c9m5x8w3t6v1qbg", "run_id": "run_01k6rz5a9d3f6g2h8j4k7m1n5p", "reason_type": "liability", "summary": "Customer reports the perfume caused a skin reaction", "status": "open", "requested_by": "agent", "priority": "high", "expires_at": 1791358500 } }}and three headers:
| Header | Value |
|---|---|
webhook-id |
The event ID (evt_…). It stays the same on every retry — use it to deduplicate. |
webhook-timestamp |
Unix seconds when this attempt was signed. |
webhook-signature |
One or more space-separated signatures, each v1,<base64>. |
3. Verify the signature
Section titled “3. Verify the signature”The signature is an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of your secret after whsec_. Verify the raw bytes of the body — before any JSON parsing — compare in constant time, and reject timestamps more than five minutes away from your clock. These implementations are tested against the Standard Webhooks test vector:
// Node.js + Expressimport crypto from 'node:crypto';import express from 'express';
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(rawBody, headers, secret) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const signatures = headers['webhook-signature']; if (!id || !timestamp || !signatures) throw new Error('Missing webhook headers');
const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!(age <= TOLERANCE_SECONDS)) throw new Error('Timestamp outside the tolerance window');
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = crypto .createHmac('sha256', key) .update(`${id}.${timestamp}.`) .update(rawBody) .digest();
const valid = signatures.split(' ').some((entry) => { const [version, signature] = entry.split(','); if (version !== 'v1' || !signature) return false; const given = Buffer.from(signature, 'base64'); return given.length === expected.length && crypto.timingSafeEqual(given, expected); }); if (!valid) throw new Error('Invalid signature'); return JSON.parse(rawBody.toString('utf8'));}
const app = express();
// express.raw keeps the exact bytes that were signed.app.post('/kagent', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = verifyWebhook(req.body, req.headers, process.env.KAGENT_WEBHOOK_SECRET); } catch { return res.sendStatus(400); } res.sendStatus(204); // acknowledge first, then process queue.push(event); // your own job queue; deduplicate on event.id});# Flaskimport base64, hashlib, hmac, json, os, timefrom flask import Flask, abort, request
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(raw_body: bytes, headers, secret: str) -> dict: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not (msg_id and timestamp and signatures): raise ValueError("missing webhook headers") if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: raise ValueError("timestamp outside the tolerance window")
key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "): version, _, signature = entry.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return json.loads(raw_body) raise ValueError("invalid signature")
app = Flask(__name__)
@app.post("/kagent")def kagent_webhook(): try: event = verify_webhook(request.get_data(), request.headers, os.environ["KAGENT_WEBHOOK_SECRET"]) except ValueError: abort(400) enqueue(event) # your own job queue; deduplicate on event["id"] return "", 204package main
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "io" "log" "net/http" "os" "strconv" "strings" "time")
const tolerance = 5 * time.Minute
func verifyWebhook(body []byte, h http.Header, secret string) error { id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature") if id == "" || ts == "" || sigs == "" { return errors.New("missing webhook headers") } sec, err := strconv.ParseInt(ts, 10, 64) if err != nil { return errors.New("invalid timestamp") } if d := time.Since(time.Unix(sec, 0)); d > tolerance || d < -tolerance { return errors.New("timestamp outside the tolerance window") } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return errors.New("invalid secret") } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(body) expected := mac.Sum(nil) for _, entry := range strings.Fields(sigs) { version, sig, ok := strings.Cut(entry, ",") if !ok || version != "v1" { continue } if given, err := base64.StdEncoding.DecodeString(sig); err == nil && hmac.Equal(given, expected) { return nil } } return errors.New("invalid signature")}
func main() { secret := os.Getenv("KAGENT_WEBHOOK_SECRET") http.HandleFunc("POST /kagent", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil || verifyWebhook(body, r.Header, secret) != nil { http.Error(w, "invalid webhook", http.StatusBadRequest) return } w.WriteHeader(http.StatusNoContent) // json.Unmarshal(body, &event), then process; deduplicate on the event ID. }) log.Fatal(http.ListenAndServe(":8000", nil))}<?php// webhook.php (PHP 8+)const TOLERANCE_SECONDS = 300;
function verify_webhook(string $rawBody, string $id, string $timestamp, string $signatures, string $secret): array{ if ($id === '' || $timestamp === '' || $signatures === '') { throw new RuntimeException('Missing webhook headers'); } if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) { throw new RuntimeException('Timestamp outside the tolerance window'); } $key = base64_decode(str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret, true); if ($key === false) { throw new RuntimeException('Invalid secret'); } $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));
foreach (explode(' ', $signatures) as $entry) { [$version, $signature] = array_pad(explode(',', $entry, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $signature)) { return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); } } throw new RuntimeException('Invalid signature');}
try { $event = verify_webhook( file_get_contents('php://input'), $_SERVER['HTTP_WEBHOOK_ID'] ?? '', $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', getenv('KAGENT_WEBHOOK_SECRET'), );} catch (Throwable $e) { http_response_code(400); exit;}
http_response_code(204);// Process $event['type'] and $event['data']; deduplicate on $event['id'].Official Standard Webhooks libraries exist for many languages too (standardwebhooks on npm and PyPI); they accept the same whsec_ secret.
4. Respond fast, process later
Section titled “4. Respond fast, process later”- Return any 2xx status within 15 seconds. Anything else, or a timeout, counts as a failure.
- Acknowledge first, then do the work in a background job.
- Deduplicate on
webhook-id(the eventid): a delivery can arrive more than once. - Don’t rely on order. Use
created_at, or fetch the current state from the API (GET /v1/handoffs/{id},GET /v1/sessions/{id}).
Retries
Section titled “Retries”Failed deliveries are retried after about 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, then marked failed. An endpoint that keeps failing for 5 days is disabled, with a reason, and the webhook_endpoint.disabled event is sent to your other endpoints.
| Task | Call |
|---|---|
Send a test event (ping, or a sample of the type you pass) |
POST /v1/webhook_endpoints/{whep}/test |
| See recent deliveries and responses | GET /v1/webhook_endpoints/{whep}/deliveries |
| Retry one delivery now | POST /v1/webhook_deliveries/{whd}/retry |
| Change URL, events or status | PATCH /v1/webhook_endpoints/{whep} |
| Browse the event log | GET /v1/events, GET /v1/events/{evt} |
Events are kept for 30 days.