ThunderPhone 2.0 متاح الآن.خدمة ذاتية، ابتداءً من 2 سنت/دقيقة.اقرأ الإعلان

Operations

التحقق من توقيعات 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=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t هو وقت 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 على نص، لذا تنتهي سلسلتها الموقَّعة بالسطر الجديد بعد المسار

للتحقق:

  1. قبل أي تحليل، اقرأ الرأس والطريقة والمسار الخام والاستعلام ونص الطلب الخام.
  2. خذ t وكل قيمة v1 من الرأس.
  3. ارفض الطلب إذا كانت t قبل ساعتك أو بعدها بأكثر من 300 ثانية.
  4. احسب HMAC-SHA256 السداسي عشري للسلسلة الموقَّعة باستخدام سرك.
  5. قارنه بـ 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 أو أحدث). تتحقق من نافذة الخمس دقائق، وتقارن بزمن ثابت، وتقبل قائمة من الأسرار لكي تتمكن من تدويرها دون توقف الخدمة.

Python
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
)
Node.js
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

Python
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)
Node.js
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.

FastAPI
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}
Express
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);
  },
);
Django
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 — توقيع النص الأساسي فقط القديم، باستخدام المفتاح نفسه.

يوجد اختلافان عن خطافات الويب:

  1. لا تحتوي أدوات GET / DELETE على نص أساسي. تنتقل الوسائط كمعلمات استعلام. يغطي توقيع V2 هذه المعلمات عبر المسار والاستعلام، ويكون حقل النص الأساسي فارغًا. تحقّق باستخدام سلسلة الاستعلام الخام: قد يؤدي تحليل المعلمات وإنشاء سلسلة استعلام جديدة إلى تغيير ترتيبها أو ترميزها. توقّع الترويسة القديمة سلسلة البايتات الفارغة.
  2. المؤسسات التي لم تُعِد خطاف ويب قديمًا لا تملك سرًا للمؤسسة. في هذه الحالة، لا تحمل استدعاءات الأدوات سوى 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.

  1. اقرأ نص الطلب الخام — البايتات الدقيقة التي أرسلناها إليك.
  2. احسب hmac_sha256(secret, body).hexdigest().
  3. قارن في وقت ثابت مع 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.

Python
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 "")
Node.js
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),
  );
}
Go
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))
}
Ruby
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 إذا فشل التحقق.


الخطوات التالية