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 |
| נתיב ושאילתה | יעד הבקשה משורת בקשת 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 יחידה) — לא סוד לכל נקודת קצה |
שמרו את הסוד במנהל הסודות או במשתנה הסביבה שלכם — לעולם אל תבצעו לו commit.
אימות קריאות לכלים
כאשר הסוכן מפעיל ישירות אחד
מכלי הפונקציה שלכם (לכלי יש
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).
הפצת כלים במצב webhook (כלים ללא endpoint, הנשלחים ל-webhook
הארגוני שלכם כ-telephony.tool / web.tool) היא webhook רגיל עם
חתימה — המתכון הסטנדרטי שלמעלה חל. ראו כלי פונקציות
לשתי צורות הבקשה.
חתימה מדור קודם: 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מלכודות נפוצות
סטיית שעון
חלון הזמן של 5 דקות נמדד מול שעון השרת שלכם. שמרו אותו מסונכרן עם NTP; שעון שסוטה ביותר מ-5 דקות ידחה כל בקשה.
Proxy משכתב את הנתיב
הנתיב החתום הוא הנתיב שביקשנו: נתיב ה-URL שהגדרתם ב-ThunderPhone. אם proxy הפוך או שער API מסיר או מוסיף קידומת לפני שהיישום שלכם רואה את הבקשה, בנו מחדש את הנתיב ששלחנו (לדוגמה, הוסיפו את הקידומת בחזרה) לפני האימות.
סידור טורי מחדש עם עיצוב ברירת המחדל
פענוח הגוף והמרתו מחדש באמצעות הגדרות ברירת המחדל של
ספריית ה-JSON שלכם (רווחים אחרי , / :, מפתחות לפי סדר הכנסה) יוצר
בתים שונים ושובר את ה-HMAC. אמתו את הגוף הגולמי — או אם
עליכם לבצע סידור טורי מחדש, התאימו בדיוק לצורה הקנונית שלנו: מפתחות
ממוינים, מפרידים קומפקטיים, UTF-8.
המסגרת מנתחת JSON באופן אוטומטי
התווכה express.json() של Express צורכת את זרם הגוף
ואתם מאבדים את הבתים הגולמיים. השתמשו ב-express.raw() במיוחד בנתיב
ה-webhook, או אחסנו את הגוף הגולמי בחוצץ בתווכה מקדימה.
אותו הדבר נכון עבור NestJS / Koa — עיינו בתיעוד שלהם בנושא "גוף גולמי".
השוואה שאינה בטוחה מבחינת זמן
expected === signature ב-JS או expected == signature ב-
Python הן השוואות שמשך הזמן שלהן משתנה. השתמשו ב-crypto.timingSafeEqual
או ב-hmac.compare_digest בהתאמה. ההבדל בביצועים
זניח.
סוד שגוי עבור נקודות קצה של כלים
קריאות ישירות לנקודות קצה של כלים נחתמות באמצעות סוד ה-webhook
ברמת הארגון (GET /v1/webhook) — ולא באמצעות סוד כלשהו לכל נקודת קצה
מתוך /v1/developer/webhook-endpoints. השתמשו מחדש באותו מסייע,
אך ודאו שאתם מעבירים לו את סוד הארגון בנתיבי כלים.
מחרוזות שאילתה בכלי GET/DELETE
V2 חותם על מחרוזת השאילתה בדיוק כפי שנשלחה. הכותרת הישנה חותמת על הגוף הריק ולעולם לא על מחרוזת השאילתה, לכן גיבוב ה-URL עבור הכותרת הישנה לעולם לא יתאים.
כפילויות בתוך חלון הזמן
חותמת הזמן מונעת הפעלות חוזרות שגילן עולה על 5 דקות, ולא עותק שני
בתוך חלון זה. מסירות לנקודות קצה מתבצעות לפחות פעם אחת, לכן גם
ניסיון חוזר של אותו אירוע עשוי להגיע; הסירו כפילויות לפי
event_id של המעטפה.
אי-החזרת 401 במקרה של אי-התאמה
החזרת 200 כאשר האימות נכשל הופכת את המטפל ליעד להפעלה חוזרת. השיבו תמיד בקוד שאינו 2xx אם האימות נכשל.