# Webhooks

> Receive K-Agent events — handoffs, replies, tickets, usage alerts — signed with Standard Webhooks, with verification code for JavaScript, Python, Go and PHP.

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](https://www.standardwebhooks.com/), so you can prove it came from us.

## 1. Create an endpoint

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

## 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](/docs/en/concepts/handoff-and-safety/#handoffs) 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

Every delivery is a `POST` with a JSON body:

```json
{
  "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

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:

**JavaScript**

```js
// 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
});
```

**Python**

```python
# Flask
import base64, hashlib, hmac, json, os, time
from 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 "", 204
```

**Go**

```go
package 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**

```php
<?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

- 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}`).

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