Open in
ตรวจสอบลายเซ็น Webhook
Webhook และคำขอเครื่องมือทุกรายการที่ ThunderPhone ส่งจะมีการลงลายเซ็น ตรวจสอบลายเซ็นหนึ่งครั้งด้วยแนวทางในหน้านี้ แล้วนำการตรวจสอบเดียวกันนี้ไปใช้ซ้ำกับทุกเอนด์พอยต์ที่คุณใช้งาน
ทุกคำขอที่เราส่งไปยังเซิร์ฟเวอร์ของคุณ — ทั้งการส่ง webhook และการเรียกใช้ tool endpoint — จะมีลายเซ็น HMAC-SHA256 อยู่ในส่วนหัว X-ThunderPhone-Signature ตรวจสอบให้ถูกต้องเพียงครั้งเดียว แล้วใช้ตัวช่วยเดียวกันกับ handler ทุกตัว
อัลกอริทึม
- อ่านเนื้อหาคำขอ แบบดิบ — ไบต์ที่ตรงตามที่เรา POST ให้คุณทุกประการ
- คำนวณ
hmac_sha256(secret, body).hexdigest() - เปรียบเทียบกับ
X-ThunderPhone-Signatureแบบ เวลาคงที่ (การเปรียบเทียบสตริงแบบทั่วไปจะเปิดเผยข้อมูลด้านเวลา)
เราลงลายเซ็นบนไบต์ที่ส่งจริงทุกประการ ดังนั้นการตรวจสอบเนื้อหาแบบดิบจึงใช้ได้เสมอ ไบต์เหล่านั้นยังเป็น การทำให้ JSON เป็นรูปแบบมาตรฐาน
ของ payload ด้วย — คีย์เรียงตามตัวอักษร ตัวคั่นแบบกระชับ
(, และ : โดยไม่มีช่องว่าง) และ UTF-8 ซึ่งให้วิธีที่สองที่เทียบเท่ากันโดยสมบูรณ์ เมื่อเฟรมเวิร์กของคุณเปิดเผยเฉพาะ JSON ที่แยกวิเคราะห์แล้ว:
ทำให้เป็นรูปแบบมาตรฐานอีกครั้ง แล้วคำนวณ HMAC จากผลลัพธ์นั้น
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")ควรใช้เนื้อหาแบบดิบ — มีขั้นตอนน้อยกว่า และไม่ถูกรบกวนจากลักษณะเฉพาะของการแปลงตัวเลข JSON ไปกลับในบางภาษา
ใช้ secret ใด
| แหล่งที่มา | Secret |
|---|---|
Webhook endpoint (/v1/developer/webhook-endpoints) | secret เฉพาะ endpoint (อักขระ hex 48 ตัว) ที่ส่งกลับเพียงครั้งเดียวเมื่อสร้าง |
| Webhook URL เดียวแบบเดิม | secret เฉพาะองค์กรที่ส่งกลับจาก GET /v1/webhook |
การเรียกใช้ Tool endpoint (เรียกไปยัง endpoint.url ของคุณโดยตรง) | webhook secret ระดับองค์กร (ค่าเดียวกับ webhook URL เดียวแบบเดิม) — ไม่ใช่ secret เฉพาะ endpoint |
จัดเก็บ secret ไว้ในตัวจัดการ secret หรือตัวแปรสภาพแวดล้อม — ห้าม commit โดยเด็ดขาด
ตัวอย่างการติดตั้งใช้งานอ้างอิง
ทั้งสี่ตัวอย่างตรวจสอบเนื้อหาคำขอแบบดิบ:
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การเชื่อมต่อเฉพาะเฟรมเวิร์ก
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}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);
},
);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 ที่ใช้คีย์เป็น ความลับเว็บฮุกระดับองค์กร กับไบต์ของเนื้อความคำขอที่ตรงกันทุกประการ
ตัวช่วย verify() เดียวกันใช้งานได้โดยไม่ต้องเปลี่ยนแปลง โดยมีข้อควรทราบสองประการ:
- เครื่องมือ
GET/DELETEไม่มีเนื้อความ อาร์กิวเมนต์จะส่งผ่านพารามิเตอร์คิวรี และลายเซ็นจะคำนวณจาก สตริงไบต์ว่าง — ดังนั้นใช้verify(b"", sig, secret)(Python) หรือverify(Buffer.alloc(0), sig, secret)(Node) อย่าแฮช สตริงคิวรี - องค์กรที่ไม่ได้กำหนดค่าเว็บฮุกแบบเดิมจะไม่มีความลับระดับองค์กร ในกรณีนั้น
การเรียกใช้เครื่องมือจะมีเพียง
X-ThunderPhone-Call-IDและไม่มีส่วนหัวลายเซ็น กำหนดค่าเว็บฮุกแบบเดิม (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)
...การส่งต่อเครื่องมือในโหมดเว็บฮุก (เครื่องมือที่ไม่มี endpoint ซึ่งส่งไปยัง
เว็บฮุกขององค์กรคุณเป็น telephony.tool / web.tool) คือเว็บฮุกที่ลงนามตามปกติ —
ใช้ขั้นตอนมาตรฐานข้างต้น ดู
เครื่องมือฟังก์ชัน สำหรับรูปแบบคำขอทั้งสองแบบ
ข้อผิดพลาดที่พบบ่อย
ซีเรียลไลซ์ซ้ำด้วยการจัดรูปแบบเริ่มต้น
การแยกวิเคราะห์เนื้อหาแล้วดัมพ์ซ้ำด้วยค่าเริ่มต้นของไลบรารี 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 เสมอหากการตรวจสอบล้มเหลว