Open in
التحقق من توقيعات webhook
يُوقَّع كل طلب webhook وطلب أداة يرسله ThunderPhone بطابع زمني. تحقّق منه باستخدام الوصفة أو مساعد SDK هنا، وارفض الطلبات المعاد تشغيلها التي مضى عليها أكثر من خمس دقائق.
يحمل كل طلب نرسله إلى خادمك (عمليات تسليم webhook وwebhook القديم ذي عنوان URL واحد واستدعاءات نقاط نهاية الأدوات) رأسي توقيع. كلاهما يستخدم HMAC-SHA256 مع السر نفسه.
| الرأس | يوقّع | الحماية من إعادة الإرسال |
|---|---|---|
X-ThunderPhone-Signature-V2 (موصى به) | الطابع الزمني والطريقة والمسار والاستعلام والنص | ارفض الطلبات التي يزيد عمرها على 5 دقائق |
X-ThunderPhone-Signature | النص فقط | لا توجد: يظل الطلب الملتقط صالحًا للتحقق إلى الأبد |
تحقق من X-ThunderPhone-Signature-V2 في عمليات التكامل الجديدة، وانقل عمليات التكامل الحالية إليه. لا يزال X-ThunderPhone-Signature يُرسل مع كل طلب دون تغيير، لذا تواصل أدوات التحقق المبنية عليه العمل. سنعلن عن تاريخ قبل أن نتوقف عن إرساله.
التوقيع V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etهو وقت Unix، بالثواني، عند إرسالنا الطلب. يُوقَّع التسليم المعاد مرة أخرى عند إرساله، باستخدامtخاص به.v1هو HMAC-SHA256 سداسي عشري للسلسلة الموقَّعة أدناه، باستخدام السر الخاص بك كمفتاح.
القيمة عبارة عن قائمة مفصولة بفواصل من أزواج key=value. تجاهل المفاتيح التي
لا تتعرف عليها، واقبل الطلب إذا تطابق أي v1: فقد نضيف
توقيعًا ثانيًا أثناء تغيير مستقبلي.
السلسلة الموقَّعة
أربعة حقول، يتبع كلٌّ منها سطر جديد واحد (\n، البايت 0x0A) باستثناء
الأخير:
<t>\n<METHOD>\n<path and query>\n<body>| الحقل | القيمة |
|---|---|
t | قيمة t من الرأس |
METHOD | طريقة HTTP بأحرف كبيرة: POST أو GET أو PUT أو PATCH أو DELETE |
| path and query | هدف الطلب من سطر طلب HTTP: المسار، ثم ? وسلسلة الاستعلام عند وجودها. بدون مخطط أو مضيف أو جزء. استخدمه تمامًا كما وصل، دون فك ترميزه أو إعادة ترتيب المعلمات |
body | نص الطلب الخام. لا تحتوي استدعاءات الأدوات GET وDELETE على نص، لذا تنتهي سلسلتها الموقَّعة بالسطر الجديد بعد المسار |
للتحقق:
- قبل أي تحليل، اقرأ الرأس والطريقة والمسار الخام والاستعلام ونص الطلب الخام.
- خذ
tوكل قيمةv1من الرأس. - ارفض الطلب إذا كانت
tقبل ساعتك أو بعدها بأكثر من 300 ثانية. - احسب HMAC-SHA256 السداسي عشري للسلسلة الموقَّعة باستخدام سرك.
- قارنه بـ
v1بزمن ثابت. أرسل استجابة401إذا لم يتطابق أيٌّ منها.
مثال عملي
السر whsec_example_4c1f2a9b. نرسل هذا الطلب عند
t = 1767225600 (2026-01-01 00:00:00 UTC):
POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
{"customer_id":"cust_123"}السلسلة الموقَّعة هي هذه الأسطر الأربعة، دون سطر جديد بعد النص:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}قيمة HMAC-SHA256 الخاصة بها هي
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
تحقق من تنفيذك بمقارنتها، أو أعد إنتاجها باستخدام openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'تحتوي استدعاء أداة GET إلى /tools/lookup?customer_id=cust_123 في الثانية
نفسها على نص فارغ. سلسلتها الموقَّعة هي
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n وتوقيعها
هو 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
باستخدام حزم SDK
تتضمن حزمتا SDK لـ Python وTypeScript دالة مساعدة
(thunderphone بالإصدار 0.2.0 أو أحدث، و@thunderphone/sdk بالإصدار 0.2.0 أو أحدث). تتحقق
من نافذة الخمس دقائق، وتقارن بزمن ثابت، وتقبل قائمة
من الأسرار لكي تتمكن من تدويرها دون توقف الخدمة.
from thunderphone import verify_signature
ok = verify_signature(
header, # the X-ThunderPhone-Signature-V2 value
secret=SECRET, # or [NEW_SECRET, OLD_SECRET] while rotating
method=method, # "POST"
path=path, # "/tools/lookup?region=us", exactly as received
body=raw_body, # bytes, before any JSON parsing
tolerance=300, # optional; seconds
)import { verifySignature } from "@thunderphone/sdk";
const ok = verifySignature({
header, // the X-ThunderPhone-Signature-V2 value
secret: SECRET, // or [NEW_SECRET, OLD_SECRET] while rotating
method, // "POST"
path, // "/tools/lookup?region=us", exactly as received
body: rawBody, // Buffer or string, before any JSON parsing
toleranceSeconds: 300, // optional
});بدون حزمة SDK
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
"""path is the path plus ?query exactly as received; body is the raw body."""
timestamp, signatures = None, []
for item in (header or "").split(","):
key, _, value = item.strip().partition("=")
if key == "t" and value.isdigit() and len(value) <= 15:
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
let timestamp;
const signatures = [];
for (const item of (header || "").split(",")) {
const [key, value = ""] = item.trim().split("=", 2);
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1") signatures.push(value);
}
if (timestamp === undefined) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
crypto
.createHmac("sha256", secret)
.update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
.update(body)
.digest("hex"),
);
return signatures.some((sig) => {
const candidate = Buffer.from(sig);
return (
candidate.length === expected.length &&
crypto.timingSafeEqual(candidate, expected)
);
});
}ربط الإطار
يقرأ كل مثال المسار والاستعلام الخامَّين والنص الخام، ثم يستدعي مساعد حزمة SDK.
import json
from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature
app = FastAPI()
def raw_path_and_query(request: Request) -> str:
path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
query = request.scope.get("query_string", b"").decode("latin-1")
return f"{path}?{query}" if query else path
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
import { verifySignature } from "@thunderphone/sdk";
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 ok = verifySignature({
header: req.header("X-ThunderPhone-Signature-V2"),
secret: process.env.WEBHOOK_SECRET,
method: req.method,
path: req.originalUrl, // path and query as received
body: req.body,
});
if (!ok) 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 HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature
@csrf_exempt
@require_POST
def hook(request):
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=request.get_full_path(),
body=request.body, # raw bytes
):
return HttpResponseForbidden("invalid signature")
event = json.loads(request.body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})يمثل req.originalUrl (Express) وكلٌّ من raw_path وquery_string في ASGI
(FastAPI) الهدف كما تم استلامه تمامًا. يفك get_full_path() في Django
وrequest.path في Flask ترميز المسار أولًا، ما ينتج عنه السلسلة نفسها للمسارات
العادية. إذا كان مسار نقطة النهاية لديك يحتوي على أحرف مرمزة بنسبة مئوية أو
محجوزة مثل %2F أو ; أو =، فاقرأ الهدف الخام الذي يوفره خادمك بدلًا من ذلك
(مثل request.META["RAW_URI"] في
Gunicorn).
أي سر؟
يوقّع السر نفسه كلا الرأسين.
| المصدر | السر |
|---|---|
نقطة نهاية Webhook (/v1/developer/webhook-endpoints) | secret لكل نقطة نهاية (48 حرفًا سداسيًا عشريًا) يُعاد مرة واحدة عند الإنشاء |
| Webhook القديم بعنوان URL واحد | secret لكل مؤسسة يُعاد عند GET /v1/webhook |
استدعاء نقطة نهاية أداة (استدعاء مباشر إلى endpoint.url لديك) | سر webhook على مستوى المؤسسة (وهو نفسه المستخدم في webhook القديم بعنوان URL واحد) — وليس سرًا لكل نقطة نهاية |
خزّن السر في مدير الأسرار أو متغير البيئة لديك — ولا تلتزم به في المستودع مطلقًا.
التحقق من استدعاءات الأدوات
عندما يستدعي الوكيل إحدى
أدوات الدوال مباشرةً (تحتوي الأداة على
endpoint)، يحمل الطلب ترويسات ThunderPhone هذه إلى جانب
endpoint.headers الذي أعددته:
X-ThunderPhone-Call-ID— المعرّف الرقمي للمكالمة المباشرة.X-ThunderPhone-Signature-V2— التوقيع المؤرخ زمنيًا أعلاه، والمشفّر باستخدام سر خطاف الويب على مستوى المؤسسة.X-ThunderPhone-Signature— توقيع النص الأساسي فقط القديم، باستخدام المفتاح نفسه.
يوجد اختلافان عن خطافات الويب:
- لا تحتوي أدوات
GET/DELETEعلى نص أساسي. تنتقل الوسائط كمعلمات استعلام. يغطي توقيع V2 هذه المعلمات عبر المسار والاستعلام، ويكون حقل النص الأساسي فارغًا. تحقّق باستخدام سلسلة الاستعلام الخام: قد يؤدي تحليل المعلمات وإنشاء سلسلة استعلام جديدة إلى تغيير ترتيبها أو ترميزها. توقّع الترويسة القديمة سلسلة البايتات الفارغة. - المؤسسات التي لم تُعِد خطاف ويب قديمًا لا تملك سرًا للمؤسسة. في
هذه الحالة، لا تحمل استدعاءات الأدوات سوى
X-ThunderPhone-Call-IDولا تحمل أي ترويسات توقيع. أعِد خطاف الويب القديم (PUT /v1/webhook) للحصول على سر توقيع، أو صادِق على استدعاءات الأدوات باستخدام ترويسة خاصة بك عبرendpoint.headers.
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=ORG_WEBHOOK_SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
args = json.loads(body) if body else dict(request.query_params)
...في Express، يترك محلل النص الأساسي req.body ككائن فارغ عند استخدام GET،
لذا مرّر Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
إرسال الأدوات في وضع خطاف الويب (الأدوات التي لا تحتوي على endpoint، والتي
تصل إلى خطاف ويب مؤسستك بصيغة telephony.tool / web.tool) هو خطاف ويب
موقّع عادي — تنطبق الوصفة القياسية أعلاه. راجع
أدوات الدوال لكلا شكلي الطلب.
التوقيع القديم: X-ThunderPhone-Signature
الترويسة الأصلية هي قيمة HMAC-SHA256 بالترميز الست عشري للنص الخام وحده، باستخدام السر نفسه. لا تحمل طابعًا زمنيًا، لذا يمكن لأي شخص يلتقط طلبًا واحدًا موقّعًا إعادة تشغيله. ولا تزال تُرسل مع كل طلب أثناء انتقال عمليات التكامل إلى V2.
- اقرأ نص الطلب الخام — البايتات الدقيقة التي أرسلناها إليك.
- احسب
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 في بعض اللغات. وينطبق الأمر نفسه على حقل النص في V2.
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الأخطاء الشائعة
انحراف الساعة
تُقاس نافذة الخمس دقائق مقارنةً بساعة خادمك. أبقِها متزامنة مع NTP؛ فالساعة التي يتجاوز انحرافها 5 دقائق ترفض كل طلب.
وكيل يعيد كتابة المسار
المسار الموقّع هو المسار الذي طلبناه: مسار عنوان URL الذي أعددته في ThunderPhone. إذا كان وكيل عكسي أو بوابة API يزيل بادئة أو يضيفها قبل أن يرى تطبيقك الطلب، فأعد إنشاء المسار الذي أرسلناه (مثلًا، أعد إضافة البادئة) قبل التحقق.
إعادة التسلسل بالتنسيق الافتراضي
يؤدي تحليل جسم الطلب ثم تفريغه مجددًا باستخدام الإعدادات
الافتراضية لمكتبة JSON لديك (مسافات بعد , / :, ومفاتيح مرتبة حسب الإدراج) إلى إنتاج
بايتات مختلفة وتعطيل HMAC. تحقّق من جسم الطلب الخام — أو إذا
اضطررت إلى إعادة تسلسله، فطابق صيغتنا القياسية تمامًا: مفاتيح
مرتبة، وفواصل مضغوطة، وUTF-8.
إطار العمل يحلل JSON تلقائيًا
تستهلك برمجية Express الوسيطة express.json() تدفق النص
وتفقد البايتات الخام. استخدم express.raw() تحديدًا في مسار
webhook، أو خزّن النص الخام مؤقتًا في برمجية وسيطة تسبقها.
وينطبق الأمر نفسه على NestJS / Koa — راجع وثائقهما حول "النص الخام".
مقارنة غير آمنة زمنيًا
تُعد expected === signature في JS أو expected == signature في
Python مقارنات متغيرة التوقيت. استخدم crypto.timingSafeEqual
أو hmac.compare_digest على التوالي. فرق الأداء
معدوم.
سر خاطئ لنقاط نهاية الأدوات
تُوقَّع استدعاءات نقاط نهاية الأدوات المباشرة باستخدام سر خطاف الويب
على مستوى المؤسسة (GET /v1/webhook) — وليس باستخدام أي سر خاص بنقطة نهاية
من /v1/developer/webhook-endpoints. أعد استخدام المساعد نفسه،
لكن تأكد من تمرير سر المؤسسة إليه في مسارات الأدوات.
سلاسل الاستعلام في أدوات GET/DELETE
يوقّع V2 سلسلة الاستعلام كما أُرسلت تمامًا. توقّع الترويسة القديمة جسم الطلب الفارغ ولا توقّع سلسلة الاستعلام أبدًا، لذا فإن تجزئة عنوان URL من أجل الترويسة القديمة لن تتطابق أبدًا.
التكرارات داخل النافذة
يمنع الطابع الزمني إعادات التشغيل الأقدم من 5 دقائق، وليس نسخة ثانية
ضمن تلك النافذة. تُسلَّم الطلبات إلى نقاط النهاية مرة واحدة على الأقل، لذا قد
تصل أيضًا إعادة محاولة للحدث نفسه؛ أزل التكرار بالاعتماد على
event_id الخاص بالغلاف.
عدم إرجاع 401 عند عدم التطابق
إن إرجاع 200 عند فشل التحقق يجعل المعالج هدفًا لإعادة التشغيل. استجب دائمًا برمز غير 2xx إذا فشل التحقق.