ThunderPhone 2.0 באוויר.בשירות עצמי, החל מ-2¢/דקה.קראו את הודעת ההשקה

Operations

אימות חתימות webhook

כל בקשת webhook וכל בקשת כלי ש-ThunderPhone שולחת חתומות. אמתו את החתימה פעם אחת באמצעות המתכון כאן, ולאחר מכן השתמשו שוב באותה בדיקה בכל נקודת קצה שאתם מפעילים.

כל בקשה שאנו שולחים לשרת שלכם — מסירות webhook והפעלות של נקודות קצה לכלים — כוללת חתימת HMAC-SHA256 בכותרת X-ThunderPhone-Signature. הגדירו את האימות פעם אחת והשתמשו באותו מסייע בכל מטפל.

האלגוריתם

  1. קראו את גוף הבקשה הגולמי — הבתים המדויקים ששלחנו אליכם ב-POST.
  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 בשפות מסוימות.

איזה סוד?

מקורסוד
נקודת קצה של webhook (/v1/developer/webhook-endpoints)secret לכל נקודת קצה (48 תווי הקסדצימליים), שמוחזר פעם אחת בעת היצירה
webhook מדור קודם עם כתובת URL יחידהsecret לכל ארגון, שמוחזר ב-GET /v1/webhook
הפעלה של נקודת קצה לכלי (קריאה ישירה אל endpoint.url שלכם)סוד ה-webhook ברמת הארגון (אותו סוד כמו ב-webhook המדור קודם עם כתובת URL יחידה) — לא סוד לכל נקודת קצה

שמרו את הסוד במנהל הסודות או במשתנה הסביבה שלכם — לעולם אל תבצעו לו commit.

מימושי עזר

כל ארבעת המימושים מאמתים את גוף הבקשה הגולמי:

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

חיבור ייעודי למסגרת

FastAPI
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}
Express
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);
  },
);
Django
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, עם מפתח שהוא סוד ה-webhook ברמת הארגון, על פני הבתים המדויקים של גוף הבקשה.

אותו מסייע verify() פועל ללא שינוי, עם שתי הסתייגויות:

  1. לכלי GET / DELETE אין גוף. הארגומנטים מועברים כפרמטרים של שאילתה, והחתימה מחושבת על פני מחרוזת בתים ריקה — לכן השתמשו ב- verify(b"", sig, secret) ‏(Python) או verify(Buffer.alloc(0), sig, secret) ‏(Node). אל תחשבו גיבוב של מחרוזת השאילתה.
  2. לארגונים ללא webhook מדור קודם מוגדר אין סוד ארגוני. במקרה כזה קריאות לכלים כוללות רק X-ThunderPhone-Call-ID וללא כותרת חתימה. הגדירו את ה-webhook מדור קודם (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)
    ...

הפצת כלים במצב webhook (כלים ללא endpoint, הנשלחים ל-webhook הארגוני שלכם כ-telephony.tool / web.tool) היא webhook רגיל עם חתימה — המתכון הסטנדרטי שלמעלה חל. ראו כלי פונקציות לשתי צורות הבקשה.

מלכודות נפוצות

סידור מחדש עם עיצוב ברירת מחדל

ניתוח גוף הבקשה וייצוא שלו מחדש עם הגדרות ברירת המחדל של ספריית ה-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. השתמשו מחדש באותה פונקציית verify() , אך ודאו שאתם מעבירים לה את סוד הארגון בנתיבי כלים.

גיבוב מחרוזת השאילתה בכלי GET/DELETE

עבור שיטות כלים ללא גוף, החתימה מכסה את מחרוזת הבתים הריקה, וכך נשמר מתכון אוניברסלי אחד: חשבו HMAC על גוף הבקשה הגולמי, יהיה אשר יהיה. גיבוב כתובת ה-URL או מחרוזת השאילתה לעולם לא יתאים.

אי-החזרת 401 במקרה של אי-התאמה

החזרת 200 כאשר האימות נכשל הופכת את המטפל ליעד להפעלה חוזרת. השיבו תמיד בקוד שאינו 2xx אם האימות נכשל.


השלבים הבאים