ThunderPhone 2.0 yayında.Hemen kendiniz kullanmaya başlayın; dakikası 2 sentten başlayan fiyatlarla.Duyuruyu okuyun

Operations

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ığı verilerYeniden oynatma koruması
X-ThunderPhone-Signature-V2 (önerilen)Zaman damgası, yöntem, yol, sorgu ve gövde5 dakikadan eski istekleri reddedin
X-ThunderPhone-SignatureYalnızca gövdeYok: 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=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t, isteği gönderdiğimiz andaki Unix zamanıdır ve saniye cinsindendir. Yeniden denenen bir teslimat, gönderildiğinde kendi t değ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>
AlanDeğer
tBaşlıktaki t değeri
METHODBüyük harfli HTTP yöntemi: POST, GET, PUT, PATCH veya DELETE
path and queryHTTP 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
bodyHam 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:

  1. Herhangi bir ayrıştırmadan önce başlığı, yöntemi, ham yolu ve sorguyu ve ham gövdeyi okuyun.
  2. Başlıktan t ve tüm v1 değerlerini alın.
  3. t değeri saatinizden 300 saniyeden fazla önce veya sonra ise isteği reddedin.
  4. Gizli anahtarınızla imzalı dizenin onaltılık HMAC-SHA256 değerini hesaplayın.
  5. Sabit zamanda v1 ile karşılaştırın. Hiçbiri eşleşmezse 401 yanı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.

Python
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
)
Node.js
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

Python
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)
Node.js
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.

FastAPI
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}
Express
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);
  },
);
Django
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.

KaynakGizli 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 webhookGET /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:

  1. GET / DELETE araç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.
  2. 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-ID iç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.headers aracı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.

  1. Ham istek gövdesini — size gönderdiğimiz tam baytları — okuyun.
  2. hmac_sha256(secret, body).hexdigest() hesaplayın.
  3. X-ThunderPhone-Signature ile 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.

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

Yaygı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.


Sonraki adımlar