# الويب هوك

> استقبل أحداث K-Agent — التحويلات والردود والتذاكر وتنبيهات الاستخدام — موقّعة وفق Standard Webhooks، مع كود التحقق بلغات JavaScript وPython وGo وPHP.

يخبر الويب هوك أنظمتك بما يحدث في محادثاتك لحظة حدوثه: تحويل يجب أن يستلمه فريقك، أو رد يُوصَل عبر قناة أخرى، أو تذكرة جديدة، أو تنبيه استخدام. يوقّع K-Agent كل إرسال وفق [Standard Webhooks](https://www.standardwebhooks.com/)، فتستطيع إثبات أنه صادر منا.

## 1. أنشئ نقطة استقبال

```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": "نظام الدعم",
    "events": ["handoff.requested", "handoff.resolved", "message.created", "ticket.created"]
  }'
```

تتضمن الاستجابة سر التوقيع `secret` ‏(`whsec_…`) **مرة واحدة** — احفظه كما تحفظ كلمة المرور. وغيّره عبر `POST /v1/webhook_endpoints/{whep}/rotate_secret`، الذي يعرض السر الجديد مرة واحدة أيضًا.

- `events` تسرد أنواع الأحداث المطلوبة، أو `["*"]` (الافتراضي) لكل الأحداث. والنوع غير الموجود في القائمة أدناه يعيد `422 unknown_event_type`.
- يجب أن يكون الرابط `https://` عامًا على المنفذ 443 أو 8443؛ وتُرفض العناوين الخاصة والداخلية، ولا تُتبع التحويلات (redirects).
- للمشروع 10 نقاط استقبال كحد أقصى.

## 2. الأحداث

| الحدث | يُرسل حين |
|---|---|
| `message.created` | تُحفظ رسالة من العميل أو المساعد أو رسالة `human_agent` عامة. لا تُضمَّن الملاحظات الداخلية أبدًا. |
| `note.created` | يضيف فريقك ملاحظة داخلية. |
| `run.completed` و`run.failed` و`run.requires_action` | ينتهي تشغيل، أو يفشل، أو ينتظر نتائج أدوات جهة العميل. |
| `session.created` و`session.updated` و`session.closed` | تبدأ جلسة، أو يتغير وضعها أو حالتها، أو تُغلق. |
| `handoff.requested` و`handoff.assigned` و`handoff.unclaimed` و`handoff.resolved` و`handoff.expired` | دورة حياة [التحويل لموظف](/docs/concepts/handoff-and-safety/#التحويل-لموظف). |
| `ticket.created` | فتح الوكيل (أو الكود الخاص بك) تذكرة. |
| `conversation.flagged` | وسم الوكيل محادثة للمراجعة. |
| `usage.threshold_reached` | بلغ الاستخدام 75% أو 100% من الخطة (`{percent}`). |
| `knowledge_source.sync_failed` | فشل تحديث مصدر معرفة من واجهة برمجية. |
| `webhook_endpoint.disabled` | عُطّلت نقطة استقبال بعد فشلها 5 أيام. |

أحداث لوحة التجربة في لوحة التحكم لا تُرسل أبدًا.

### جسم الحدث

كل إرسال طلب `POST` بجسم JSON:

```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": "العميل يذكر أن العطر سبّب حساسية في الجلد",
      "status": "open",
      "requested_by": "agent",
      "priority": "high",
      "expires_at": 1791358500
    }
  }
}
```

وثلاث ترويسات:

| الترويسة | القيمة |
|---|---|
| `webhook-id` | معرّف الحدث (`evt_…`). يبقى ثابتًا في كل إعادة محاولة — استخدمه لمنع التكرار. |
| `webhook-timestamp` | ثوانٍ بتوقيت يونكس لحظة توقيع هذه المحاولة. |
| `webhook-signature` | توقيع واحد أو أكثر تفصل بينها مسافات، كلٌّ بالصيغة `v1,<base64>`. |

## 3. تحقّق من التوقيع

التوقيع HMAC-SHA256 للنص `{webhook-id}.{webhook-timestamp}.{raw body}`، ومفتاحه الجزء الواقع بعد `whsec_` في سرك بعد فك ترميز base64. تحقّق من **البايتات الخام** للجسم — قبل أي تحليل JSON — وقارن بزمن ثابت، وارفض الطوابع الزمنية التي تبعد أكثر من خمس دقائق عن ساعتك. هذه التطبيقات مُختبَرة على متجه الاختبار الخاص بـ Standard Webhooks:

**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'].
```

وتوجد مكتبات Standard Webhooks الرسمية للغات كثيرة أيضًا (`standardwebhooks` على npm وPyPI)، وهي تقبل سر `whsec_` نفسه.

## 4. أجب بسرعة وعالج لاحقًا

- أعد أي حالة **2xx** خلال 15 ثانية. وأي حالة أخرى، أو انتهاء المهلة، يُعدّ فشلًا.
- أكّد الاستلام أولًا، ثم أنجز العمل في مهمة خلفية.
- **امنع التكرار** بالاعتماد على `webhook-id` (معرّف الحدث `id`): قد يصل الإرسال نفسه أكثر من مرة.
- لا تعتمد على الترتيب. استخدم `created_at`، أو اجلب الحالة الحالية من الواجهة البرمجية (`GET /v1/handoffs/{id}` و`GET /v1/sessions/{id}`).

## إعادة المحاولة

يُعاد إرسال الطلبات الفاشلة بعد نحو 5 ثوانٍ، ثم 5 دقائق، ثم 30 دقيقة، ثم ساعتين، ثم 5 ساعات، ثم 10 ساعات، ثم 10 ساعات، ثم يُعلَّم الإرسال فاشلًا. ونقطة الاستقبال التي يستمر فشلها 5 أيام تُعطَّل مع ذكر السبب، ويُرسل الحدث `webhook_endpoint.disabled` إلى نقاطك الأخرى.

| المهمة | الطلب |
|---|---|
| إرسال حدث تجريبي (`ping`، أو عينة من النوع `type` الذي تمرره) | `POST /v1/webhook_endpoints/{whep}/test` |
| عرض الإرسالات الأخيرة واستجاباتها | `GET /v1/webhook_endpoints/{whep}/deliveries` |
| إعادة إرسال واحد الآن | `POST /v1/webhook_deliveries/{whd}/retry` |
| تغيير الرابط أو الأحداث أو الحالة | `PATCH /v1/webhook_endpoints/{whep}` |
| تصفح سجل الأحداث | `GET /v1/events` و`GET /v1/events/{evt}` |

تُحفظ الأحداث 30 يومًا.
