Open in
Webhook imzalarını doğrulama
ThunderPhone tarafından gönderilen her webhook ve araç isteği bir zaman damgasıyla imzalanır. Buradaki yöntem veya SDK yardımcısıyla doğrulayın ve beş dakikadan eski tekrar oynatılmış istekleri reddedin.
Sunucunuza gönderdiğimiz her istek (webhook teslimleri, eski tek URL'li webhook ve araç uç noktası çağrıları) iki imza başlığı içerir. Her ikisi de aynı gizli anahtarla HMAC-SHA256 kullanır.
| Başlık | İmzaladığı veriler | Yeniden oynatma koruması |
|---|---|---|
X-ThunderPhone-Signature-V2 (önerilen) | Zaman damgası, yöntem, yol, sorgu ve gövde | 5 dakikadan eski istekleri reddedin |
X-ThunderPhone-Signature | Yalnızca gövde | Yok: ele geçirilen bir istek her zaman doğrulanır |
Yeni entegrasyonlarda X-ThunderPhone-Signature-V2 doğrulamasını yapın ve
mevcut entegrasyonları buna taşıyın. X-ThunderPhone-Signature her
istekte değişmeden gönderilmeye devam eder; bu nedenle buna dayalı doğrulayıcılar
çalışmaya devam eder. Göndermeyi durdurmadan önce bir tarih duyuracağız.
İmza V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4et, isteği gönderdiğimiz andaki Unix zamanıdır ve saniye cinsindendir. Yeniden denenen bir teslimat, gönderildiğinde kenditdeğeriyle tekrar imzalanır.v1, aşağıdaki imzalı dizenin, gizli anahtarınız kullanılarak oluşturulmuş onaltılık HMAC-SHA256 değeridir.
Değer, virgülle ayrılmış key=value çiftlerinin listesidir. Tanımadığınız anahtarları
yok sayın ve herhangi bir v1 eşleşirse isteği kabul edin: gelecekteki bir değişiklik sırasında
ikinci bir imza ekleyebiliriz.
İmzalı dize
Her biri sonuncu hariç bir yeni satırla (\n, bayt 0x0A) biten dört alan:
<t>\n<METHOD>\n<path and query>\n<body>| Alan | Değer |
|---|---|
t | Başlıktaki t değeri |
METHOD | Büyük harfli HTTP yöntemi: POST, GET, PUT, PATCH veya DELETE |
| path and query | HTTP istek satırındaki istek hedefi: yol, ardından varsa ? ve sorgu dizesi. Şema, ana makine veya parça içermez. Kodunu çözmeden ya da parametreleri yeniden sıralamadan, geldiği şekliyle kullanın |
body | Ham istek gövdesi. GET ve DELETE araç çağrılarının gövdesi yoktur; bu nedenle imzalı dizeleri yolun ardından gelen yeni satırla biter |
Doğrulamak için:
- Herhangi bir ayrıştırmadan önce başlığı, yöntemi, ham yolu ve sorguyu ve ham gövdeyi okuyun.
- Başlıktan
tve tümv1değerlerini alın. tdeğeri saatinizden 300 saniyeden fazla önce veya sonra ise isteği reddedin.- Gizli anahtarınızla imzalı dizenin onaltılık HMAC-SHA256 değerini hesaplayın.
- Sabit zamanda
v1ile karşılaştırın. Hiçbiri eşleşmezse401yanıtını verin.
Uygulamalı örnek
Gizli anahtar whsec_example_4c1f2a9b. Bu isteği
t = 1767225600 (2026-01-01 00:00:00 UTC) anında gönderiyoruz:
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"}İmzalı dize, gövdeden sonra yeni satır olmadan şu dört satırdır:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}HMAC-SHA256 değeri
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Uygulamanızı buna göre kontrol edin veya openssl ile yeniden oluşturun:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Aynı saniyede /tools/lookup?customer_id=cust_123 adresine yapılan bir GET
araç çağrısının gövdesi boştur. İmzalı dizgesi
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, imzası ise
3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280 olur.
SDK'lerle
Python ve TypeScript SDK'leri, bir yardımcı içerir
(thunderphone 0.2.0 veya üzeri, @thunderphone/sdk 0.2.0 veya üzeri). Bu yardımcı
5 dakikalık pencereyi denetler, sabit zamanda karşılaştırır ve kesinti olmadan
anahtar rotasyonu yapabilmeniz için bir gizli anahtar listesini kabul eder.
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 olmadan
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)
);
});
}Çerçeve bağlantısı
Her örnek ham yolu ve sorguyu, ardından ham gövdeyi okur ve SDK yardımcısını çağırır.
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) ile ASGI raw_path ve query_string
(FastAPI), hedefi alındığı biçimiyle tam olarak içerir. Django'nun
get_full_path() yöntemi ve Flask'ın request.path değeri önce yolu çözer;
bu, normal yollar için aynı dizeyi verir. Uç nokta yolunuz %2F, ; veya =
gibi yüzde kodlu ya da ayrılmış karakterler içeriyorsa, bunun yerine sunucunuzun
sağladığı ham hedefi okuyun (örneğin Gunicorn'da request.META["RAW_URI"]).
Hangi gizli anahtar?
Her iki başlık da aynı gizli anahtarla imzalanır.
| Kaynak | Gizli anahtar |
|---|---|
Webhook uç noktası (/v1/developer/webhook-endpoints) | Oluşturulurken bir kez döndürülen, uç nokta başına secret (48 hex karakter) |
| Eski tek URL'li webhook | GET /v1/webhook çağrısında döndürülen, kuruluş başına secret |
Araç uç noktası çağrısı (endpoint.url adresinize doğrudan çağrı) | Kuruluş düzeyindeki webhook gizli anahtarı (eski tek URL'li webhook ile aynı anahtar) — uç nokta başına bir gizli anahtar değildir |
Gizli anahtarı gizli anahtar yöneticinizde veya ortam değişkeninde saklayın — asla sürüm kontrolüne eklemeyin.
Araç çağrılarını doğrulama
Yapay zeka ajanı fonksiyon araçlarınızdan birini doğrudan çağırdığında (aracın bir endpoint değeri vardır), istek yapılandırdığınız endpoint.headers ile birlikte şu ThunderPhone başlıklarını içerir:
X-ThunderPhone-Call-ID— canlı çağrının sayısal kimliği.X-ThunderPhone-Signature-V2— kuruluş düzeyindeki webhook gizli anahtarınızla oluşturulan, yukarıdaki zaman damgalı imza.X-ThunderPhone-Signature— aynı anahtarı kullanan, yalnızca gövdeye yönelik eski imza.
Webhook'lara kıyasla iki fark vardır:
GET/DELETEaraçlarının gövdesi yoktur. Bağımsız değişkenler sorgu parametreleri olarak iletilir. V2 imzası bunları yol ve sorgu üzerinden kapsar; gövde alanı boştur. Ham sorgu dizesiyle doğrulayın: parametreleri ayrıştırıp yeni bir sorgu dizesi oluşturmak, sıralarını veya kodlamalarını değiştirebilir. Eski başlık boş bayt dizesini imzalar.- Eski bir webhook yapılandırılmamış kuruluşların kuruluş gizli anahtarı yoktur. Bu durumda araç çağrıları yalnızca
X-ThunderPhone-Call-IDiçerir ve imza başlıkları içermez. İmzalama gizli anahtarı almak için eski webhook'u (PUT /v1/webhook) yapılandırın veya araç çağrılarınıendpoint.headersaracılığıyla kendi başlığınızla kimlik doğrulayın.
@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'te bir gövde ayrıştırıcısı, GET isteğinde req.body değerini boş nesne olarak bırakır; bu nedenle Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) iletin.
Webhook modunda araç yönlendirmesi (endpoint içermeyen ve kuruluş
webhook'unuza telephony.tool / web.tool olarak iletilen araçlar) normal
bir imzalı webhook'tur — yukarıdaki standart yöntem geçerlidir. Her iki
istek biçimi için İşlev Araçları sayfasına bakın.
Eski imza: X-ThunderPhone-Signature
Orijinal başlık, yalnızca ham gövdenin aynı gizli anahtarla hesaplanan onaltılık HMAC-SHA256 değeridir. Zaman damgası içermez; bu nedenle imzalı bir isteği ele geçiren herkes isteği yeniden iletebilir. Entegrasyonlar V2'ye geçerken her istekte gönderilmeye devam eder.
- Ham istek gövdesini — size gönderdiğimiz tam baytları — okuyun.
hmac_sha256(secret, body).hexdigest()hesaplayın.X-ThunderPhone-Signatureile sabit zamanda karşılaştırın.
İlettiğimiz baytları tam olarak imzalarız. Bu baytlar aynı zamanda yükün kanonik JSON serileştirmesidir — anahtarlar alfabetik olarak sıralanır, ayırıcılar sıkışıktır (boşluksuz , ve :), kodlama UTF-8'dir. Çerçeveniz yalnızca ayrıştırılmış JSON'u sunuyorsa, kanonik olarak yeniden serileştirin ve bunun HMAC'ini hesaplayın:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Ham gövdeyi tercih edin — bu, bir adım daha az gerektirir ve bazı dillerde JSON sayılarının ileri geri dönüştürülmesindeki tuhaflıklara karşı dayanıklıdır. Aynı durum V2'nin gövde alanı için de geçerlidir.
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)
endYaygın hatalar
Saat sapması
5 dakikalık pencere, sunucunuzun saatine göre ölçülür. Saati NTP ile eşzamanlı tutun; 5 dakikadan fazla sapmış bir saat her isteği reddeder.
Bir proxy yolu yeniden yazar
İmzalanan yol, talep ettiğimiz yoldur: ThunderPhone'da yapılandırdığınız URL'nin yolu. Ters proxy veya API ağ geçidi, uygulamanız isteği görmeden önce bir öneki kaldırır ya da eklerse doğrulamadan önce gönderdiğimiz yolu yeniden oluşturun (örneğin, öneki geri ekleyin).
Varsayılan biçimlendirmeyle yeniden serileştirme
Gövdeyi ayrıştırıp JSON kitaplığınızın varsayılanlarıyla yeniden
dökmek (, / : sonrasında boşluklar, ekleme sıralı anahtarlar)
farklı baytlar üretir ve HMAC'i bozar. Ham gövdeyi doğrulayın — veya
yeniden serileştirmeniz gerekiyorsa kanonik biçimimizle tam olarak eşleştirin:
sıralanmış anahtarlar, sıkıştırılmış ayırıcılar, UTF-8.
Çerçeve JSON'u otomatik ayrıştırıyor
Express'in express.json() ara yazılımı gövde akışını tüketir
ve ham baytları kaybedersiniz. Webhook rotasında özellikle express.raw() kullanın
veya ham gövdeyi bir ön ara yazılımda arabelleğe alın.
NestJS / Koa için de aynı durum geçerlidir — "ham gövde" belgelerini kontrol edin.
Zamanlama açısından güvenli olmayan karşılaştırma
JS'de expected === signature veya Python'da expected == signature
zamanlamaya göre değişen karşılaştırmalardır. Sırasıyla crypto.timingSafeEqual
veya hmac.compare_digest kullanın. Performans farkı yok denecek kadar azdır.
Araç uç noktaları için yanlış gizli anahtar
Doğrudan araç uç noktası çağrıları, kuruluş düzeyindeki webhook
gizli anahtarı (GET /v1/webhook) ile imzalanır —
/v1/developer/webhook-endpoints içindeki uç noktaya özgü herhangi bir gizli anahtarla değil. Aynı yardımcıyı
yeniden kullanın, ancak araç yollarında ona kuruluş gizli anahtarını
verdiğinizden emin olun.
GET/DELETE araçlarındaki sorgu dizeleri
V2, sorgu dizesini gönderildiği şekliyle imzalar. Eski başlık, boş gövdeyi imzalar ve sorgu dizesini hiçbir zaman imzalamaz; bu nedenle eski başlık için URL'yi özetlemek hiçbir zaman eşleşmez.
Pencere içindeki yinelenenler
Zaman damgası, pencere içindeki ikinci bir kopyayı değil, 5 dakikadan eski
yeniden oynatmaları engeller. Uç nokta teslimatları en az bir kez gerçekleşir; bu nedenle
aynı olayın yeniden denemesi de gelebilir; zarfın event_id değerine göre
tekilleştirin.
Eşleşmezse 401 döndürmemek
Doğrulama başarısız olduğunda 200 döndürmek, işleyiciyi yeniden oynatma saldırılarının hedefi hâline getirir. Doğrulama başarısız olursa her zaman 2xx olmayan bir yanıt verin.