Open in
ตรวจสอบลายเซ็น webhook
webhook และคำขอเครื่องมือทุกรายการที่ ThunderPhone ส่งจะมีลายเซ็นพร้อมการประทับเวลา ตรวจสอบด้วยตัวอย่างหรือฟังก์ชันช่วยของ SDK ที่นี่ และปฏิเสธคำขอที่ถูกส่งซ้ำซึ่งเก่ากว่าห้านาที
ทุกคำขอที่เราส่งไปยังเซิร์ฟเวอร์ของคุณ (การส่ง webhook, webhook แบบ URL เดียวรุ่นเดิม และการเรียก tool endpoint) มีส่วนหัวลายเซ็นสองรายการ ทั้งสองใช้ HMAC-SHA256 ด้วย secret เดียวกัน
| ส่วนหัว | ลงลายเซ็นให้ | การป้องกันการส่งซ้ำ |
|---|---|---|
X-ThunderPhone-Signature-V2 (แนะนำ) | การประทับเวลา เมธอด พาธ query และเนื้อหา | ปฏิเสธคำขอที่เก่ากว่า 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 |
| path and query | เป้าหมายคำขอจากบรรทัดคำขอ 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 หรือใหม่กว่า) ซึ่งตรวจสอบช่วงเวลา 5 นาที เปรียบเทียบแบบเวลาคงที่ และยอมรับรายการซีเคร็ตเพื่อให้คุณหมุนเวียนซีเคร็ตได้โดยไม่เกิดช่วงหยุดให้บริการ
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) คือเป้าหมายตามที่ได้รับมาอย่างแม่นยำ Django
get_full_path() และ request.path ของ Flask จะถอดรหัสพาธก่อน ซึ่งให้
สตริงเดียวกันสำหรับพาธทั่วไป หากพาธของเอนด์พอยต์มีอักขระที่เข้ารหัสเปอร์เซ็นต์หรืออักขระสงวน เช่น %2F, ; หรือ = ให้ใช้
เป้าหมายดิบที่เซิร์ฟเวอร์ของคุณมีให้แทน (ตัวอย่างเช่น request.META["RAW_URI"] บน
Gunicorn)
ใช้ 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 โดยเด็ดขาด
การตรวจสอบการเรียกใช้เครื่องมือ
เมื่อเอเจนต์เรียกใช้
เครื่องมือฟังก์ชัน รายการใดรายการหนึ่งโดยตรง (เครื่องมือนั้นมี
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) ผ่านไป
การส่งต่อเครื่องมือในโหมดเว็บฮุก (เครื่องมือที่ไม่มี endpoint ซึ่งส่งไปยัง
เว็บฮุกขององค์กรคุณเป็น telephony.tool / web.tool) คือเว็บฮุกที่ลงนามตามปกติ —
ใช้ขั้นตอนมาตรฐานข้างต้น ดู
เครื่องมือฟังก์ชัน สำหรับรูปแบบคำขอทั้งสองแบบ
ลายเซ็นแบบเดิม: X-ThunderPhone-Signature
เฮดเดอร์ดั้งเดิมคือค่า hex ของ HMAC-SHA256 สำหรับ raw body เพียงอย่างเดียว โดยใช้ secret เดียวกัน ไม่มี timestamp ดังนั้นผู้ที่ดักจับคำขอที่ลงลายเซ็นไว้หนึ่งรายการ สามารถส่งซ้ำได้ ระบบจะยังคงส่งเฮดเดอร์นี้ในทุกคำขอระหว่างที่อินทิเกรชันย้ายไปยัง V2
- อ่านเนื้อหาคำขอ ดิบ — ไบต์ที่ตรงกับที่เราส่งให้คุณทุกประการ
- คำนวณ
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")ควรใช้ raw body เพราะมีขั้นตอนน้อยกว่า และไม่รับผลกระทบจากความแตกต่างในการแปลงกลับไปกลับมาของตัวเลข JSON ในบางภาษา หลักการเดียวกันนี้ใช้กับฟิลด์ body ของ 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 นาทีจะปฏิเสธทุก คำขอ
พร็อกซีเขียนพาธใหม่
พาธที่ลงลายเซ็นคือพาธที่เราร้องขอ ได้แก่พาธของ URL ที่คุณ กำหนดค่าใน ThunderPhone หากรีเวิร์สพร็อกซีหรือเกตเวย์ 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 เสมอหากการตรวจสอบล้มเหลว