ThunderPhone 2.0 đã chính thức ra mắt.Tự thiết lập, từ 2 xu/phút.Xem thông báo ra mắt

Operations

Xác minh chữ ký webhook

Mọi yêu cầu webhook và công cụ mà ThunderPhone gửi đều được ký. Xác minh chữ ký một lần bằng công thức tại đây, rồi dùng lại cùng bước kiểm tra đó trên mọi endpoint bạn vận hành.

Mọi yêu cầu chúng tôi gửi đến máy chủ của bạn — lần gửi webhook và lần gọi endpoint công cụ — đều có chữ ký HMAC-SHA256 trong header X-ThunderPhone-Signature. Xác minh đúng một lần rồi dùng cùng helper đó trong mọi handler.

Thuật toán

  1. Đọc phần thân yêu cầu thô — chính xác các byte chúng tôi POST đến bạn.
  2. Tính hmac_sha256(secret, body).hexdigest().
  3. So sánh theo thời gian hằng số với X-ThunderPhone-Signature. (So sánh chuỗi thông thường làm lộ thông tin thời gian.)

Chúng tôi ký chính xác các byte được truyền đi, vì vậy việc xác minh phần thân thô luôn hoạt động. Các byte đó cũng là bản tuần tự hóa JSON chuẩn của payload — khóa được sắp xếp theo thứ tự chữ cái, dấu phân cách rút gọn (,: không có khoảng trắng), UTF-8. Điều này cung cấp cho bạn cách thứ hai, hoàn toàn tương đương khi framework của bạn chỉ cung cấp JSON đã phân tích: tuần tự hóa lại theo chuẩn và tính HMAC trên dữ liệu đó.

# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")

Ưu tiên phần thân thô — ít hơn một bước và không bị ảnh hưởng bởi các đặc thù khi chuyển đổi qua lại số JSON trong một số ngôn ngữ.

Dùng secret nào?

NguồnSecret
Endpoint webhook (/v1/developer/webhook-endpoints)secret theo từng endpoint (48 ký tự hex) chỉ được trả về một lần khi tạo
Webhook URL đơn cũsecret theo tổ chức được trả về qua GET /v1/webhook
Lần gọi endpoint công cụ (gọi trực tiếp đến endpoint.url của bạn)Secret webhook cấp tổ chức (giống secret của webhook URL đơn cũ) — không phải secret theo từng endpoint

Lưu secret trong trình quản lý secret hoặc biến môi trường — không bao giờ commit secret.

Triển khai tham chiếu

Cả bốn đều xác minh phần thân yêu cầu thô:

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

Thiết lập dành riêng cho framework

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})

Xác minh lệnh gọi công cụ

Khi tác nhân AI gọi trực tiếp một trong các công cụ hàm của bạn (công cụ có endpoint), yêu cầu sẽ mang hai header ThunderPhone cùng với endpoint.headers mà bạn đã cấu hình:

  • X-ThunderPhone-Call-ID — id số của cuộc gọi đang diễn ra.
  • X-ThunderPhone-Signature — HMAC-SHA256, được khóa bằng secret webhook cấp tổ chức của bạn, trên chính xác các byte của phần thân yêu cầu.

Cùng một helper verify() hoạt động không cần thay đổi, với hai điểm cần lưu ý:

  1. Công cụ GET / DELETE không có phần thân. Đối số được truyền dưới dạng tham số truy vấn và chữ ký được tính trên chuỗi byte rỗng — vì vậy dùng verify(b"", sig, secret) (Python) hoặc verify(Buffer.alloc(0), sig, secret) (Node). Không băm chuỗi truy vấn.
  2. Tổ chức không cấu hình webhook cũ sẽ không có secret cấp tổ chức. Trong trường hợp đó, lệnh gọi công cụ chỉ mang X-ThunderPhone-Call-ID và không có header chữ ký. Cấu hình webhook cũ (PUT /v1/webhook) để nhận secret ký, hoặc xác thực lệnh gọi công cụ bằng header riêng của bạn qua 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)
    ...

Điều phối công cụ ở chế độ webhook (công cụ không có endpoint, được gửi đến webhook tổ chức của bạn dưới dạng telephony.tool / web.tool) là một webhook đã ký thông thường — áp dụng quy trình chuẩn ở trên. Xem Công cụ hàm để biết cả hai dạng yêu cầu.

Các lỗi thường gặp

Tuần tự hóa lại với định dạng mặc định

Phân tích nội dung rồi xuất lại bằng các thiết lập mặc định của thư viện JSON (dấu cách sau , / :, khóa theo thứ tự chèn) sẽ tạo ra các byte khác và làm HMAC không hợp lệ. Xác minh nội dung thô — hoặc nếu bắt buộc phải tuần tự hóa lại, hãy khớp chính xác dạng chuẩn của chúng tôi: khóa được sắp xếp, dấu phân cách gọn, UTF-8.

Framework tự động phân tích JSON

Middleware express.json() của Express tiêu thụ luồng nội dung và bạn sẽ mất các byte thô. Hãy dùng express.raw() riêng cho route webhook hoặc đệm nội dung thô trong middleware tiền xử lý. NestJS / Koa cũng tương tự — hãy xem tài liệu về "raw body" của chúng.

So sánh không an toàn về thời gian

expected === signature trong JS hoặc expected == signature trong Python là các phép so sánh có thời gian thay đổi. Hãy dùng crypto.timingSafeEqual hoặc hmac.compare_digest tương ứng. Chênh lệch hiệu năng là không đáng kể.

Dùng sai secret cho endpoint công cụ

Các lệnh gọi trực tiếp đến endpoint công cụ được ký bằng webhook secret cấp tổ chức (GET /v1/webhook) — không phải bằng bất kỳ secret riêng cho endpoint nào từ /v1/developer/webhook-endpoints. Dùng lại cùng hàm verify(), nhưng hãy đảm bảo bạn truyền secret của tổ chức vào các route công cụ.

Băm chuỗi truy vấn trên công cụ GET/DELETE

Với các phương thức công cụ không có nội dung, chữ ký bao phủ chuỗi byte rỗng, duy trì một quy trình chung: HMAC nội dung yêu cầu thô, bất kể nội dung đó là gì. Băm URL hoặc chuỗi truy vấn sẽ không bao giờ khớp.

Không trả về 401 khi không khớp

Trả về 200 khi xác minh thất bại khiến handler trở thành mục tiêu phát lại. Luôn phản hồi không phải 2xx nếu xác minh thất bại.


Bước tiếp theo