الويب هوك
يخبر الويب هوك أنظمتك بما يحدث في محادثاتك لحظة حدوثه: تحويل يجب أن يستلمه فريقك، أو رد يُوصَل عبر قناة أخرى، أو تذكرة جديدة، أو تنبيه استخدام. يوقّع K-Agent كل إرسال وفق Standard Webhooks، فتستطيع إثبات أنه صادر منا.
1. أنشئ نقطة استقبال
رابط القسم «1. أنشئ نقطة استقبال»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. الأحداث
رابط القسم «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 |
دورة حياة التحويل لموظف. |
ticket.created |
فتح الوكيل (أو الكود الخاص بك) تذكرة. |
conversation.flagged |
وسم الوكيل محادثة للمراجعة. |
usage.threshold_reached |
بلغ الاستخدام 75% أو 100% من الخطة ({percent}). |
knowledge_source.sync_failed |
فشل تحديث مصدر معرفة من واجهة برمجية. |
webhook_endpoint.disabled |
عُطّلت نقطة استقبال بعد فشلها 5 أيام. |
أحداث لوحة التجربة في لوحة التحكم لا تُرسل أبدًا.
جسم الحدث
رابط القسم «جسم الحدث»كل إرسال طلب POST بجسم 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. تحقّق من التوقيع
رابط القسم «3. تحقّق من التوقيع»التوقيع HMAC-SHA256 للنص {webhook-id}.{webhook-timestamp}.{raw body}، ومفتاحه الجزء الواقع بعد whsec_ في سرك بعد فك ترميز base64. تحقّق من البايتات الخام للجسم — قبل أي تحليل JSON — وقارن بزمن ثابت، وارفض الطوابع الزمنية التي تبعد أكثر من خمس دقائق عن ساعتك. هذه التطبيقات مُختبَرة على متجه الاختبار الخاص بـ Standard Webhooks:
// 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'].وتوجد مكتبات Standard Webhooks الرسمية للغات كثيرة أيضًا (standardwebhooks على npm وPyPI)، وهي تقبل سر whsec_ نفسه.
4. أجب بسرعة وعالج لاحقًا
رابط القسم «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 يومًا.