Webhooks
احصل على إشعارات عند حدوث أشياء في Crisphive — بدلًا من الاستطلاع المتكرّر، نرسل الأحداث إلى عنوانك عبر POST.
كيف يعمل
يسجّل أحد أعضاء الفريق نقطة نهاية HTTPS الخاصة بك في لوحة تحكم Crisphive ويختار الأحداث التي يريد استقبالها (أو جميعها). تحصل على سرّ توقيع (whsec_…) مرّة واحدة — احفظه. تختار هنا أيضًا مدة صلاحية السر — 30 يومًا افتراضيًا، ومن 1 حتى 365 (راجع عمر السر وتدويره).
يرسل التسجيل فورًا نبضة تحقق موقّعة. أجِب بـ 2xx فتُولد نقطة النهاية بحالة active؛ وإلا تبقى pending_verification (لا تستقبل شيئًا) حتى تجتاز التحقق من لوحة التحكم. تغيير العنوان يتطلّب إعادة التحقق.
عند وقوع حدث، ترسل Crisphive طلب POST إلى عنوانك مع جسم JSON وترويسة Crisphive-Signature.
تقوم نقطة النهاية الخاصة بك بـالتحقق من التوقيع، وتُرجع 2xx بسرعة، ثم تعالج الحدث بشكل غير متزامن.
أي استجابة غير 2xx أو انتهاء المهلة ⇐ نعيد المحاولة مع تراجع تصاعدي (1m, 5m, 30m, 2h, 6h). بعد 5 إخفاقات متتالية (أي نجاح يعيد ضبط العدّاد) تُعطّل نقطة النهاية تلقائيًا ويُرسَل بريد إلكتروني لمالكي/مديري النشاط التجاري. تتطلّب إعادة التفعيل تحققًا ناجحًا، وهو ما يعيد ضبط العدّاد أيضًا.
يُسجَّل كل تسليم — تعرض لوحة التحكم سجل التسليم الكامل (الحالة، المحاولات، آخر رمز/خطأ HTTP) لكل نقطة نهاية.
id الحدث (استخدم upsert، ولا تُلحق بشكل أعمى أبدًا).عمر السر وتدويره
تنتهي صلاحية أسرار توقيع webhooks، وتُدوَّر في مكانها. تختار مدة صلاحية السر عند تسجيل نقطة النهاية — 30 يومًا افتراضيًا، ومن 1 حتى 365. بخلاف مفتاح API، نقطة نهاية webhook هي عنوان URL واحد مسجَّل ولا يمكن تكرارها جنبًا إلى جنب (تسجيل العنوان نفسه مرتين سيسلّم كل حدث إليك مرتين). لذا بدلًا من نقطة نهاية ثانية، تقوم بتدوير السر:
يُصدر Developers → Webhooks → Rotate secret سرّ whsec_ جديدًا ويواصل التوقيع بالسرّين الجديد والسابق معًا لمدة 24 ساعة. يعيد التدوير أيضًا بدء مدة الصلاحية من جديد — تختار مدة جديدة أثناء التدوير (30 يومًا افتراضيًا، ومن 1 حتى 365) — لذا فهو مسار التجديد كذلك.
يُرسَل بريد إلكتروني إلى مالكي النشاط التجاري ومسؤوليه قبل 7 أيام من انتهاء صلاحية سرّ التوقيع، ومرة أخرى بعد انتهائها. نقاط النهاية المسجَّلة قبل وجود مُدد صلاحية الأسرار لا تحمل secret_expires_at وتواصل التوقيع كما كانت — وينقلها أول تدوير لها إلى مدة الصلاحية القياسية.
409 WEBHOOK_SECRET_EXPIRED بدلًا من إعادة تفعيل نقطة النهاية.كتالوج الأحداث
| نوع الحدث | متى يُطلَق |
|---|---|
job_request.created | تم إنشاء طلب عمل (حجز) — عبر واجهة API أو صفحة الحجز العامة أو لوحة التحكم. |
job_request.confirmed | أكّد العميل موعدًا زمنيًا؛ تمت جدولة العمل (وتعيين فني/طاقم). |
job_request.assigned | تم تعيين فني/طاقم للعمل أو إعادة تعيينه. |
job_request.completed | بلغ العمل حالته النهائية المكتملة. |
job_request.archived | تمت أرشفة العمل (إلغاء / إغلاق). |
job_request.status_changed | أي انتقال آخر في حالة سير العمل (مثل on_the_way أو arrived أو حالة مخصّصة). يحمل previous_status. |
job_request.priority_changed | تغيّرت أولوية العمل. |
job_request.rescheduled | نُقل العمل إلى وقت مختلف أو إلى فني/طاقم آخر. |
customer.created | تم إنشاء سجل عميل. |
customer.updated | تغيّر ملف العميل أو بيانات الاتصال أو الفئة أو الحالة. |
customer.deleted | تم حذف سجل عميل. |
technician.created | انضمّ فني إلى القائمة (بما في ذلك إعادة إضافة عضو أُزيل سابقًا). |
technician.updated | تغيّر ملف الفني أو مجموعة دوره أو فئته أو حالته. الكتابات على العلاقات فقط (الزملاء/المركبات/المهارات/مناطق الخدمة) لا تُطلق أحداثًا. |
technician.deleted | أُزيل فني من القائمة. |
يُطلق تغيّر الحالة حدثًا واحدًا بالضبط (الحدث المخصّص لتلك المرحلة إن وُجد، وإلا status_changed). الكتالوج قابل للتوسيع فقط — قد تُضاف أنواع أحداث جديدة؛ والأنواع الموجودة لا يتغيّر معناها أبدًا.
الحمولة
جسم كل تسليم هو هذا الغلاف. data.object هو المورد بالشكل نفسه الذي يُرجعه طلب GET المطابق في /v1 (اطلب /v1 للحصول على التفاصيل الكاملة/المحدّثة عند الحاجة).
{
"id": "evt_2b9c…", // unique event id — dedupe on this
"type": "job_request.completed",
"created_at": "2026-06-30T10:00:00Z",
"business_id": "b1f0…",
"environment": "live", // or "sandbox"
"data": { "object": { "id": "job_…", "short_code": "REQ-…", "status": "completed" } },
"previous_status": "arrived" // only on job_request.status_changed
}التحقق من التوقيع
صيغة الترويسة: Crisphive-Signature: t=<unix>,v1=<hex> حيث v1 = HMAC_SHA256(secret, t + "." + rawBody). احسبه على جسم الطلب الخام، وقارن بزمن ثابت، وارفضه إذا كان t قديمًا جدًا (مثلًا > 5 دقائق) لمنع إعادة التشغيل.
تحقّق مقابل قائمة توقيعات، لا قيمة واحدة. خلال فترة السماح للتدوير البالغة 24 ساعة تحمل الترويسة مُدخلَي v1:
Crisphive-Signature: t=1753857711,v1=<signature-new>,v1=<signature-old>
v1 هو إصدار خوارزمية التوقيع، لا معرّف سر. احسب HMAC بسرّك المخزّن واقبل التسليم إذا تطابق أي مُدخل v1. المستقبِل المبرمَج على قراءة أول v1 فقط سيبدأ برفض الأحداث في منتصف عملية التدوير.
Node.js
const crypto = require('crypto'); function verify(rawBody, header, secret) { const parts = header.split(',').map(kv => kv.split('=')); const t = parts.find(([k]) => k === 't')[1]; // two v1 entries during the 24h rotation window — accept any match const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v); const expected = crypto.createHmac('sha256', secret) .update(t + '.' + rawBody).digest('hex'); return sigs.some(v1 => v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))); }
Python
import hmac, hashlib def verify(raw_body: bytes, header: str, secret: str) -> bool: pairs = [kv.split('=', 1) for kv in header.split(',')] t = next(v for k, v in pairs if k == 't') # two v1 entries during the 24h rotation window — accept any match sigs = [v for k, v in pairs if k == 'v1'] expected = hmac.new(secret.encode(), t.encode() + b'.' + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, v1) for v1 in sigs)
Go
func Verify(rawBody []byte, header, secret string) bool { var t string var sigs []string // two v1 entries during the 24h rotation window for _, kv := range strings.Split(header, ",") { kvp := strings.SplitN(kv, "=", 2) switch kvp[0] { case "t": t = kvp[1] case "v1": sigs = append(sigs, kvp[1]) } } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) for _, v1 := range sigs { if hmac.Equal([]byte(expected), []byte(v1)) { return true } } return false }
Java
import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.security.MessageDigest; import java.util.*; public static boolean verify(byte[] rawBody, String header, String secret) throws Exception { String t = null; List<String> sigs = new ArrayList<>(); // two v1 entries during the 24h rotation window for (String kv : header.split(",")) { String[] x = kv.split("=", 2); if (x[0].equals("t")) t = x[1]; if (x[0].equals("v1")) sigs.add(x[1]); } Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); mac.update((t + ".").getBytes()); byte[] sig = mac.doFinal(rawBody); StringBuilder hex = new StringBuilder(); for (byte b : sig) hex.append(String.format("%02x", b)); byte[] expected = hex.toString().getBytes(); for (String v1 : sigs) { if (MessageDigest.isEqual(expected, v1.getBytes())) return true; } return false; }
C# / .NET
using System;
using System.Security.Cryptography;
using System.Collections.Generic;
using System.Text;
public static bool Verify(byte[] rawBody, string header, string secret) {
string t = null;
var sigs = new List<string>(); // two v1 entries during the 24h rotation window
foreach (var kv in header.Split(',')) {
var x = kv.Split('=', 2);
if (x[0] == "t") t = x[1];
if (x[0] == "v1") sigs.Add(x[1]);
}
var prefix = Encoding.UTF8.GetBytes(t + ".");
var input = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, input, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, input, prefix.Length, rawBody.Length);
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Encoding.UTF8.GetBytes(
Convert.ToHexString(mac.ComputeHash(input)).ToLowerInvariant());
foreach (var v1 in sigs) {
if (CryptographicOperations.FixedTimeEquals(
expected, Encoding.UTF8.GetBytes(v1))) return true;
}
return false;
}PHP
<?php function verify(string $rawBody, string $header, string $secret): bool { $t = null; $sigs = []; // two v1 entries during the 24h rotation window foreach (explode(',', $header) as $kv) { [$k, $v] = explode('=', $kv, 2); if ($k === 't') { $t = $v; } if ($k === 'v1') { $sigs[] = $v; } } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($sigs as $v1) { if (hash_equals($expected, $v1)) { return true; } } return false; }
Ruby
require 'openssl' def verify(raw_body, header, secret) pairs = header.split(',').map { |kv| kv.split('=', 2) } t = pairs.find { |k, _| k == 't' }[1] # two v1 entries during the 24h rotation window — accept any match sigs = pairs.select { |k, _| k == 'v1' }.map { |_, v| v } expected = OpenSSL::HMAC.hexdigest('SHA256', secret, t + '.' + raw_body) # constant-time compare (Rack::Utils.secure_compare / ActiveSupport::SecurityUtils.secure_compare) sigs.any? { |v1| Rack::Utils.secure_compare(expected, v1) } end
الاختبار
استخدم إرسال اختبار على نقطة النهاية في لوحة التحكم لاستقبال حدث ping موقّع والتأكد من عمل المستقبِل ومعالجة التوقيع من طرف إلى طرف (تشخيصي فقط — لا تتغيّر الحالة أبدًا). استخدم التحقق لتفعيل نقطة نهاية بحالة pending_verification أو معطّلة: نفس النبضة الموقّعة، لكن استجابة 2xx تحوّلها إلى active وتعيد ضبط عدّاد الإخفاقات. إذا كان سرّ التوقيع قد انقضى، يرفض التحقق مع WEBHOOK_SECRET_EXPIRED — دوّر السر أولًا.