Skip to content

Webhooks

View as Markdown

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.

نافذة الطرفية
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.

  • events lists the event types to receive, or ["*"] (the default) for all of them. A type that isn’t in the catalog below returns 422 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.
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.

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>.

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 + Express
import 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
});

Official Standard Webhooks libraries exist for many languages too (standardwebhooks on npm and PyPI); they accept the same whsec_ secret.

  • 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 event id): 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}).

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.