Open in
Verifikasi tanda tangan webhook
Setiap webhook dan permintaan alat yang dikirim ThunderPhone ditandatangani dengan stempel waktu. Verifikasi dengan resep atau helper SDK di sini dan tolak permintaan yang diputar ulang yang berusia lebih dari lima menit.
Setiap permintaan yang kami kirim ke server Anda (pengiriman webhook, webhook URL tunggal versi lama, dan panggilan endpoint alat) memuat dua header tanda tangan. Keduanya adalah HMAC-SHA256 dengan secret yang sama.
| Header | Menandatangani | Perlindungan replay |
|---|---|---|
X-ThunderPhone-Signature-V2 (direkomendasikan) | Stempel waktu, metode, path, kueri, dan body | Tolak permintaan yang berusia lebih dari 5 menit |
X-ThunderPhone-Signature | Hanya body | Tidak ada: permintaan yang ditangkap akan selalu terverifikasi |
Verifikasi X-ThunderPhone-Signature-V2 pada integrasi baru dan pindahkan
integrasi yang ada ke sana. X-ThunderPhone-Signature masih dikirim pada setiap
permintaan, tanpa perubahan, sehingga verifier yang dibangun dengannya tetap berfungsi. Kami akan
mengumumkan tanggal sebelum berhenti mengirimkannya.
Tanda Tangan V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etadalah waktu Unix, dalam detik, saat kami mengirim permintaan. Pengiriman yang dicoba ulang ditandatangani lagi saat dikirim, dengantmiliknya sendiri.v1adalah HMAC-SHA256 heksadesimal dari string yang ditandatangani di bawah, menggunakan secret Anda sebagai kunci.
Nilainya adalah daftar pasangan key=value yang dipisahkan koma. Abaikan kunci
yang tidak Anda kenali, dan terima permintaan jika ada v1 yang cocok: kami mungkin
menambahkan tanda tangan kedua pada perubahan di masa mendatang.
String yang ditandatangani
Empat bidang, masing-masing diikuti satu baris baru (\n, byte 0x0A) kecuali
yang terakhir:
<t>\n<METHOD>\n<path and query>\n<body>| Bidang | Nilai |
|---|---|
t | Nilai t dari header |
METHOD | Metode HTTP dalam huruf besar: POST, GET, PUT, PATCH, atau DELETE |
| path and query | Target permintaan dari baris permintaan HTTP: path, lalu ? dan string kueri jika ada. Tanpa skema, host, atau fragmen. Gunakan persis seperti saat diterima, tanpa mendekodenya atau mengubah urutan parameter |
body | Isi mentah permintaan. Panggilan tool GET dan DELETE tidak memiliki isi, sehingga string yang ditandatangani berakhir dengan baris baru setelah path |
Untuk memverifikasi:
- Sebelum melakukan parsing apa pun, baca header, metode, path dan kueri mentah, serta isi mentah.
- Ambil
tdan setiapv1dari header. - Tolak permintaan jika
tlebih dari 300 detik sebelum atau sesudah waktu Anda. - Hitung HMAC-SHA256 heksadesimal dari string yang ditandatangani menggunakan secret Anda.
- Bandingkan dengan
v1dalam waktu konstan. Respons401jika tidak ada yang cocok.
Contoh lengkap
Secret whsec_example_4c1f2a9b. Kami mengirim permintaan ini pada
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"}String yang ditandatangani adalah empat baris berikut, tanpa baris baru setelah isi:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}HMAC-SHA256-nya adalah
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Periksa implementasi Anda dengan nilai tersebut, atau buat ulang menggunakan openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Panggilan tool GET ke /tools/lookup?customer_id=cust_123 pada detik yang sama
memiliki isi kosong. String yang ditandatanganinya adalah
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n dan tanda tangannya
adalah 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Dengan SDK
SDK Python dan TypeScript menyertakan helper
(thunderphone 0.2.0 atau versi lebih baru, @thunderphone/sdk 0.2.0 atau versi lebih baru). Helper ini
memeriksa rentang 5 menit, membandingkan dalam waktu konstan, dan menerima daftar
secret sehingga Anda dapat merotasinya tanpa waktu henti.
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
});Tanpa 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)
);
});
}Integrasi framework
Setiap contoh membaca path dan query mentah serta body mentah, lalu memanggil helper 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) serta raw_path dan query_string ASGI
(FastAPI) adalah target persis seperti yang diterima. get_full_path()
Django dan request.path Flask mendekode path terlebih dahulu, sehingga
menghasilkan string yang sama untuk path biasa. Jika path endpoint Anda berisi
karakter yang dikodekan dalam persen atau karakter khusus seperti %2F, ;, atau =, baca
target mentah yang disediakan server Anda sebagai gantinya (misalnya request.META["RAW_URI"] pada
Gunicorn).
Rahasia yang mana?
Rahasia yang sama menandatangani kedua header.
| Sumber | Rahasia |
|---|---|
Endpoint webhook (/v1/developer/webhook-endpoints) | secret per endpoint (48 karakter heksadesimal) yang dikembalikan sekali saat pembuatan |
| Webhook URL tunggal lama | secret per organisasi yang dikembalikan pada GET /v1/webhook |
Pemanggilan endpoint alat (panggilan langsung ke endpoint.url Anda) | Rahasia webhook tingkat organisasi (sama dengan webhook URL tunggal lama) — bukan rahasia per endpoint |
Simpan rahasia di pengelola rahasia atau variabel environment Anda — jangan pernah melakukan commit.
Memverifikasi panggilan alat
Saat agen memanggil salah satu
alat fungsi Anda secara langsung (alat tersebut memiliki
endpoint), permintaan membawa header ThunderPhone berikut bersama
endpoint.headers yang Anda konfigurasi:
X-ThunderPhone-Call-ID— ID numerik panggilan yang sedang berlangsung.X-ThunderPhone-Signature-V2— tanda tangan bertimestamp di atas, dengan kunci rahasia webhook tingkat organisasi Anda.X-ThunderPhone-Signature— tanda tangan legacy yang hanya mencakup body, dengan kunci yang sama.
Dua hal berbeda dari webhook:
- Alat
GET/DELETEtidak memiliki body. Argumen dikirim sebagai parameter kueri. Tanda tangan V2 mencakupnya melalui path dan kueri, dan kolom body kosong. Verifikasi menggunakan string kueri mentah: mem-parsing parameter dan membuat string kueri baru dapat mengubah urutan atau encoding-nya. Header legacy menandatangani string byte kosong. - Organisasi tanpa webhook legacy yang dikonfigurasi tidak memiliki rahasia organisasi. Dalam
kasus tersebut, panggilan alat hanya membawa
X-ThunderPhone-Call-IDdan tidak memiliki header tanda tangan. Konfigurasikan webhook legacy (PUT /v1/webhook) untuk mendapatkan rahasia penandatanganan, atau autentikasi panggilan alat menggunakan header Anda sendiri melaluiendpoint.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)
...Di Express, parser body meninggalkan req.body sebagai objek kosong pada GET,
jadi teruskan Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Pengiriman alat dalam mode webhook (alat tanpa endpoint, dikirimkan
ke webhook organisasi Anda sebagai telephony.tool / web.tool) adalah webhook
bertanda tangan biasa — resep standar di atas berlaku. Lihat
Alat Fungsi untuk kedua bentuk permintaan.
Tanda tangan lama: X-ThunderPhone-Signature
Header asli adalah HMAC-SHA256 heksadesimal dari body mentah saja, dengan secret yang sama. Header ini tidak memuat stempel waktu, sehingga siapa pun yang menangkap satu permintaan bertanda tangan dapat memutarnya ulang. Header ini masih dikirim pada setiap permintaan saat integrasi beralih ke V2.
- Baca body permintaan mentah — byte persis yang kami kirim kepada Anda.
- Hitung
hmac_sha256(secret, body).hexdigest(). - Bandingkan dalam waktu konstan dengan
X-ThunderPhone-Signature.
Kami menandatangani byte persis yang kami transmisikan. Byte tersebut juga merupakan
serialisasi JSON kanonis dari payload — kunci diurutkan secara
alfabetis, pemisah ringkas (, dan : tanpa spasi), UTF-8.
Jika framework Anda hanya menyediakan JSON yang telah diurai, serialisasi ulang secara kanonis dan
gunakan HMAC untuk itu:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Sebaiknya gunakan body mentah — satu langkah lebih sedikit dan kebal terhadap kejanggalan bolak-balik angka JSON dalam beberapa bahasa. Hal yang sama berlaku untuk field 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)
endKesalahan umum
Penyimpangan jam
Jendela 5 menit diukur berdasarkan jam server Anda. Pastikan jam tersebut tersinkronisasi dengan NTP; jam yang meleset lebih dari 5 menit akan menolak setiap permintaan.
Proxy menulis ulang path
Path yang ditandatangani adalah path yang kami minta: path URL yang Anda konfigurasi di ThunderPhone. Jika proxy balik atau gateway API menghapus atau menambahkan prefiks sebelum aplikasi Anda melihat permintaan, bangun ulang path yang kami kirim (misalnya, tambahkan kembali prefiks) sebelum memverifikasi.
Serialisasi ulang dengan pemformatan default
Mengurai isi lalu membuangnya kembali dengan pengaturan default pustaka JSON Anda
(spasi setelah , / :, kunci berurutan berdasarkan penyisipan) menghasilkan
byte yang berbeda dan merusak HMAC. Verifikasi isi mentah — atau jika
Anda harus melakukan serialisasi ulang, samakan persis dengan bentuk kanonis kami: kunci
terurut, pemisah ringkas, UTF-8.
Framework mengurai JSON secara otomatis
Middleware express.json() Express mengonsumsi stream body
sehingga Anda kehilangan byte mentah. Gunakan express.raw() khusus pada rute webhook,
atau buffer body mentah dalam middleware sebelumnya.
Hal yang sama berlaku untuk NestJS / Koa — periksa dokumentasi "raw body" mereka.
Perbandingan yang tidak aman terhadap waktu
expected === signature di JS atau expected == signature di
Python adalah perbandingan dengan waktu yang bervariasi. Gunakan crypto.timingSafeEqual
atau hmac.compare_digest secara berurutan. Perbedaan performanya
tidak ada.
Rahasia yang salah untuk endpoint tool
Panggilan endpoint tool langsung ditandatangani dengan rahasia webhook
tingkat organisasi (GET /v1/webhook) — bukan dengan rahasia per-endpoint
dari /v1/developer/webhook-endpoints. Gunakan kembali helper yang sama,
tetapi pastikan Anda memberikan rahasia organisasi pada rute tool.
String kueri pada tool GET/DELETE
V2 menandatangani string kueri persis seperti yang dikirim. Header lama menandatangani isi kosong dan tidak pernah string kueri, sehingga melakukan hash pada URL untuk header lama tidak akan pernah cocok.
Duplikat dalam jendela
Timestamp menghentikan pemutaran ulang yang lebih lama dari 5 menit, bukan salinan kedua
di dalam jendela tersebut. Pengiriman endpoint bersifat setidaknya sekali, sehingga
percobaan ulang untuk event yang sama juga dapat tiba; lakukan deduplikasi berdasarkan
event_id envelope.
Tidak mengembalikan 401 saat tidak cocok
Mengembalikan 200 saat verifikasi gagal menjadikan handler sebagai target replay. Selalu respons dengan non-2xx jika verifikasi gagal.