التحقق من توقيعات webhook
يتم توقيع كل طلب webhook وأداة يرسله ThunderPhone. تحقّق من التوقيع مرة واحدة باستخدام الوصفة هنا، ثم أعد استخدام التحقق نفسه على كل نقطة نهاية تشغّلها.
يحمل كل طلب نرسله إلى خادمك — عمليات تسليم webhook واستدعاءات نقاط نهاية الأدوات — توقيع HMAC-SHA256 في الترويسة X-ThunderPhone-Signature. اضبط التحقق مرة واحدة، ثم أضف المساعد نفسه إلى كل معالج.
الخوارزمية
- اقرأ متن الطلب الخام — البايتات الدقيقة التي أرسلناها إليك عبر POST.
- احسب
hmac_sha256(secret, body).hexdigest(). - قارن في وقت ثابت مع
X-ThunderPhone-Signature. (تُسرّب مقارنة السلاسل النصية البسيطة معلومات التوقيت.)
نوقّع البايتات التي نرسلها بالضبط، لذا ينجح التحقق من المتن الخام
دائمًا. وهذه البايتات هي أيضًا تسلسل JSON الأساسي
للحمولة — مفاتيح مرتبة أبجديًا، وفواصل مضغوطة
(, و : بلا مسافات)، وترميز UTF-8. يمنحك ذلك طريقة ثانية
مكافئة تمامًا عندما لا يكشف إطار العمل لديك إلا JSON المحلّل:
أعِد تسلسله بصيغة أساسية وطبّق HMAC عليه.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")فضّل المتن الخام — فهو خطوة أقل ومحمي من مشكلات إعادة تحويل أرقام JSON في بعض اللغات.
أي سر؟
| المصدر | السر |
|---|---|
نقطة نهاية Webhook (/v1/developer/webhook-endpoints) | secret لكل نقطة نهاية (48 حرفًا سداسيًا عشريًا) يُعاد مرة واحدة عند الإنشاء |
| Webhook القديم بعنوان URL واحد | secret لكل مؤسسة يُعاد عند GET /v1/webhook |
استدعاء نقطة نهاية أداة (استدعاء مباشر إلى endpoint.url لديك) | سر webhook على مستوى المؤسسة (وهو نفسه المستخدم في webhook القديم بعنوان URL واحد) — وليس سرًا لكل نقطة نهاية |
خزّن السر في مدير الأسرار أو متغير البيئة لديك — ولا تلتزم به في المستودع مطلقًا.
تطبيقات مرجعية
تتحقق التطبيقات الأربعة جميعها من متن الطلب الخام:
import hashlib
import hmac
def verify(body: bytes, signature: str, secret: str) -> bool:
"""Constant-time HMAC-SHA256 verification."""
expected = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature or "")import crypto from "node:crypto";
export function verify(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
if (!signature || expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Verify(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(expected, signature.to_s)
endالتهيئة الخاصة بالأطر
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
raise HTTPException(status_code=401)
import json
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
const app = express();
app.post(
"/thunderphone-webhook",
// IMPORTANT: parse as raw; do NOT use express.json() here.
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-ThunderPhone-Signature") || "";
if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// … dispatch on event.type …
res.sendStatus(204);
},
);import json
from django.http import JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def hook(request):
body = request.body # raw bytes
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
return HttpResponseForbidden("invalid signature")
event = json.loads(body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})التحقق من استدعاءات الأدوات
عندما يستدعي الوكيل إحدى
أدوات الدوال مباشرةً (تحتوي الأداة على
endpoint)، يحمل الطلب رأسي ThunderPhone التاليين إلى جانب
endpoint.headers التي أعددتها:
X-ThunderPhone-Call-ID— المعرّف الرقمي للمكالمة المباشرة.X-ThunderPhone-Signature— HMAC-SHA256، باستخدام سر خطاف الويب على مستوى المؤسسة كمفتاح، على بايتات نص الطلب الدقيقة.
تعمل دالة المساعدة verify() نفسها دون تغيير، مع نقطتين يجب مراعاتهما:
- أدوات
GET/DELETEلا تحتوي على نص طلب. تنتقل الوسائط كمعلمات استعلام، ويُحسب التوقيع على سلسلة البايتات الفارغة — لذا استخدمverify(b"", sig, secret)(Python) أوverify(Buffer.alloc(0), sig, secret)(Node). لا تُجزّئ سلسلة الاستعلام. - المؤسسات التي ليس لديها خطاف ويب قديم مُعدّ لا تملك سرًا للمؤسسة. في
هذه الحالة، تحمل استدعاءات الأدوات
X-ThunderPhone-Call-IDفقط دون رأس توقيع. أعدّ خطاف الويب القديم (PUT /v1/webhook) للحصول على سر توقيع، أو صادِق استدعاءات الأدوات باستخدام رأسك الخاص عبرendpoint.headers.
@app.post("/tools/search-appointments")
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
sig = request.headers.get("X-ThunderPhone-Signature", "")
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
if not verify(body, sig, ORG_WEBHOOK_SECRET):
raise HTTPException(status_code=401)
args = json.loads(body)
...إرسال الأدوات في وضع خطاف الويب (الأدوات التي لا تحتوي على endpoint، والتي
تصل إلى خطاف ويب مؤسستك بصيغة telephony.tool / web.tool) هو خطاف ويب
موقّع عادي — تنطبق الوصفة القياسية أعلاه. راجع
أدوات الدوال لكلا شكلي الطلب.
الأخطاء الشائعة
إعادة التسلسل بالتنسيق الافتراضي
يؤدي تحليل النص وإعادة تفريغه باستخدام الإعدادات الافتراضية لمكتبة JSON لديك
(مسافات بعد , / :، ومفاتيح مرتبة حسب الإدراج) إلى إنتاج
بايتات مختلفة وتعطيل HMAC. تحقّق من النص الخام — أو إذا
اضطررت إلى إعادة التسلسل، فطابق صيغتنا القياسية تمامًا: مفاتيح
مرتبة، وفواصل مضغوطة، وترميز UTF-8.
إطار العمل يحلل JSON تلقائيًا
تستهلك برمجية Express الوسيطة express.json() تدفق النص
وتفقد البايتات الخام. استخدم express.raw() تحديدًا في مسار
webhook، أو خزّن النص الخام مؤقتًا في برمجية وسيطة تسبقها.
وينطبق الأمر نفسه على NestJS / Koa — راجع وثائقهما حول "النص الخام".
مقارنة غير آمنة زمنيًا
تُعد expected === signature في JS أو expected == signature في
Python مقارنات متغيرة التوقيت. استخدم crypto.timingSafeEqual
أو hmac.compare_digest على التوالي. فرق الأداء
معدوم.
سر خاطئ لنقاط نهاية الأدوات
تُوقَّع استدعاءات نقاط نهاية الأدوات المباشرة باستخدام سر webhook
على مستوى المؤسسة (GET /v1/webhook) — وليس باستخدام أي سر لكل نقطة
نهاية من /v1/developer/webhook-endpoints. أعد استخدام الدالة verify()
نفسها، ولكن تأكد من تزويدها بسر المؤسسة في مسارات الأدوات.
تجزئة سلسلة الاستعلام في أدوات GET/DELETE
بالنسبة إلى أساليب الأدوات التي لا تحتوي على نص، يغطي التوقيع سلسلة البايتات الفارغة، مما يحافظ على وصفة عامة واحدة: طبّق HMAC على نص الطلب الخام، مهما كان. لن تتطابق تجزئة عنوان URL أو سلسلة الاستعلام أبدًا.
عدم إرجاع 401 عند عدم التطابق
إن إرجاع 200 عند فشل التحقق يجعل المعالج هدفًا لإعادة التشغيل. استجب دائمًا برمز غير 2xx إذا فشل التحقق.