Open in
Weryfikowanie podpisów webhooków
Każdy webhook i żądanie narzędzia wysyłane przez ThunderPhone jest podpisane znacznikiem czasu. Zweryfikuj je za pomocą przepisu lub pomocnika SDK dostępnego tutaj i odrzucaj ponownie odtwarzane żądania starsze niż pięć minut.
Każde żądanie, które wysyłamy na Twój serwer (dostawy webhooków, starszy webhook z jednym adresem URL oraz wywołania punktów końcowych narzędzi), zawiera dwa nagłówki podpisu. Oba używają HMAC-SHA256 z tym samym sekretem.
| Nagłówek | Podpisuje | Ochrona przed ponownym odtworzeniem |
|---|---|---|
X-ThunderPhone-Signature-V2 (zalecany) | Sygnatura czasowa, metoda, ścieżka, zapytanie i treść | Odrzuć żądania starsze niż 5 minut |
X-ThunderPhone-Signature | Tylko treść | Brak: przechwycone żądanie można weryfikować bezterminowo |
W nowych integracjach weryfikuj X-ThunderPhone-Signature-V2 i przenieś
do niego istniejące integracje. X-ThunderPhone-Signature jest nadal wysyłany z każdym
żądaniem, bez zmian, więc weryfikatory oparte na nim nadal działają. Ogłosimy datę, zanim przestaniemy go wysyłać.
Podpis V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etto czas Unix w sekundach, w którym wysłaliśmy żądanie. Ponowiona dostawa jest podpisywana ponownie podczas wysyłania, z własnymt.v1to szesnastkowy HMAC-SHA256 podpisywanego ciągu poniżej, utworzony za pomocą Twojego sekretu.
Wartość jest listą par key=value rozdzielonych przecinkami. Ignoruj klucze,
których nie rozpoznajesz, i zaakceptuj żądanie, jeśli pasuje dowolne v1: możemy dodać
drugi podpis podczas przyszłej zmiany.
Podpisywany ciąg
Cztery pola, po każdym jeden znak nowej linii (\n, bajt 0x0A) z wyjątkiem
ostatniego:
<t>\n<METHOD>\n<path and query>\n<body>| Pole | Wartość |
|---|---|
t | Wartość t z nagłówka |
METHOD | Metoda HTTP zapisana wielkimi literami: POST, GET, PUT, PATCH lub DELETE |
| path and query | Cel żądania z wiersza żądania HTTP: ścieżka, następnie ? i ciąg zapytania, jeśli występuje. Bez schematu, hosta ani fragmentu. Użyj go dokładnie w otrzymanej postaci, bez dekodowania ani zmiany kolejności parametrów |
body | Surowe ciało żądania. Wywołania narzędzi GET i DELETE nie mają ciała, więc ich podpisywany ciąg kończy się znakiem nowej linii po ścieżce |
Aby zweryfikować:
- Przed jakimkolwiek parsowaniem odczytaj nagłówek, metodę, surową ścieżkę i zapytanie oraz surowe ciało.
- Pobierz
toraz każdev1z nagłówka. - Odrzuć żądanie, jeśli
twyprzedza lub opóźnia się względem Twojego zegara o więcej niż 300 sekund. - Oblicz szesnastkowy HMAC-SHA256 podpisywanego ciągu za pomocą swojego sekretu.
- Porównaj go z
v1w czasie stałym. Odpowiedz401, jeśli nic nie pasuje.
Przykład
Sekret whsec_example_4c1f2a9b. Wysyłamy to żądanie o
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"}Podpisywany ciąg to te cztery wiersze, bez znaku nowej linii po ciele:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Jego HMAC-SHA256 to
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Sprawdź względem niego swoją implementację albo odtwórz go za pomocą openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Wywołanie narzędzia GET do /tools/lookup?customer_id=cust_123 w tej samej
sekundzie ma puste ciało. Jego podpisywany ciąg to
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, a jego podpis
to 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Z użyciem SDK
SDK dla Pythona i TypeScript zawierają funkcję pomocniczą
(thunderphone 0.2.0 lub nowszy, @thunderphone/sdk 0.2.0 lub nowszy). Sprawdza ona
5-minutowe okno, porównuje w czasie stałym i akceptuje listę
sekretów, dzięki czemu możesz je rotować bez przestoju.
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
});Bez pakietu 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)
);
});
}Integracja z frameworkiem
Każdy przykład odczytuje surową ścieżkę i zapytanie oraz surowe ciało żądania, a następnie wywołuje funkcję pomocniczą 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) oraz ASGI raw_path i query_string
(FastAPI) stanowią docelowy adres dokładnie w otrzymanej postaci. Metody Django
get_full_path() i Flask request.path najpierw dekodują ścieżkę, co
dla zwykłych ścieżek daje ten sam ciąg znaków. Jeśli ścieżka endpointu zawiera
znaki zakodowane procentowo lub zastrzeżone, takie jak %2F, ; lub =, odczytaj
zamiast tego surowy adres docelowy udostępniany przez serwer (na przykład request.META["RAW_URI"] w
Gunicorn).
Który sekret?
Ten sam sekret podpisuje oba nagłówki.
| Źródło | Sekret |
|---|---|
Endpoint webhooka (/v1/developer/webhook-endpoints) | Sekret secret dla danego endpointu (48 znaków szesnastkowych), zwracany jednorazowo podczas tworzenia |
| Starszy webhook z pojedynczym adresem URL | Sekret secret dla organizacji zwracany przez GET /v1/webhook |
Wywołanie endpointu narzędzia (bezpośrednie wywołanie Twojego endpoint.url) | Sekret webhooka na poziomie organizacji (ten sam co dla starszego webhooka z pojedynczym adresem URL) — nie sekret dla pojedynczego endpointu |
Przechowuj sekret w menedżerze sekretów lub zmiennej środowiskowej — nigdy nie zapisuj go w repozytorium.
Weryfikowanie wywołań narzędzi
Gdy agent bezpośrednio wywołuje jedno z Twoich
narzędzi funkcyjnych (narzędzie ma
endpoint), żądanie zawiera następujące nagłówki ThunderPhone wraz
ze skonfigurowanym przez Ciebie endpoint.headers:
X-ThunderPhone-Call-ID— numeryczny identyfikator aktywnego połączenia.X-ThunderPhone-Signature-V2— oparty na znaczniku czasu podpis powyżej, powiązany z Twoim sekretem webhooka na poziomie organizacji.X-ThunderPhone-Signature— starszy podpis obejmujący tylko treść żądania, z tym samym kluczem.
Dwie rzeczy różnią się od webhooków:
- Narzędzia
GET/DELETEnie mają treści żądania. Argumenty są przekazywane jako parametry zapytania. Podpis V2 obejmuje je za pośrednictwem ścieżki i zapytania, a pole treści jest puste. Weryfikuj przy użyciu nieprzetworzonego ciągu zapytania: analizowanie parametrów i tworzenie nowego ciągu zapytania może zmienić ich kolejność lub kodowanie. Starszy nagłówek podpisuje pusty ciąg bajtów. - Organizacje bez skonfigurowanego starszego webhooka nie mają sekretu organizacji. W
takim przypadku wywołania narzędzi zawierają tylko
X-ThunderPhone-Call-IDi nie zawierają nagłówków podpisu. Skonfiguruj starszy webhook (PUT /v1/webhook), aby uzyskać sekret do podpisywania, lub uwierzytelniaj wywołania narzędzi własnym nagłówkiem za pośrednictwemendpoint.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)
...W Express parser treści żądania pozostawia req.body jako pusty obiekt przy żądaniu GET,
dlatego przekaż Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Dystrybucja wywołań narzędzi w trybie webhooka (narzędzia bez endpoint, dostarczane
do webhooka organizacji jako telephony.tool / web.tool) to zwykły
podpisany webhook — obowiązuje standardowa procedura opisana powyżej. Zobacz
Narzędzia funkcyjne, aby poznać oba formaty żądań.
Starszy podpis: X-ThunderPhone-Signature
Oryginalny nagłówek to szesnastkowy HMAC-SHA256 samego surowego treści żądania, z tym samym sekretem. Nie zawiera znacznika czasu, więc każdy, kto przechwyci jedno podpisane żądanie, może je ponownie odtworzyć. Jest nadal wysyłany przy każdym żądaniu, gdy integracje przechodzą na V2.
- Odczytaj surową treść żądania — dokładne bajty, które Ci wysłaliśmy.
- Oblicz
hmac_sha256(secret, body).hexdigest(). - Porównaj w czasie stałym z
X-ThunderPhone-Signature.
Podpisujemy dokładnie te bajty, które przesyłamy. Te bajty są również
kanoniczną serializacją JSON ładunku — klucze posortowane
alfabetycznie, zwarte separatory (, i : bez spacji), UTF-8.
Jeśli Twój framework udostępnia tylko sparsowany JSON, ponownie go zserializuj kanonicznie i
oblicz dla niego HMAC:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Preferuj surową treść — to o jeden krok mniej i zapewnia odporność na problemy z ponowną konwersją liczb JSON w niektórych językach. To samo dotyczy pola treści w 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)
endTypowe pułapki
Rozbieżność zegara
5-minutowe okno jest mierzone względem zegara Twojego serwera. Utrzymuj go zsynchronizowanego z NTP; zegar opóźniony lub przyspieszony o ponad 5 minut odrzuca każde żądanie.
Serwer proxy zmienia ścieżkę
Podpisana ścieżka to ta, o którą poprosiliśmy: ścieżka adresu URL skonfigurowanego w ThunderPhone. Jeśli odwrotny serwer proxy lub brama API usuwa albo dodaje prefiks, zanim aplikacja zobaczy żądanie, odtwórz ścieżkę, którą wysłaliśmy (na przykład przywróć prefiks), przed weryfikacją.
Ponowna serializacja z domyślnym formatowaniem
Przetworzenie treści i ponowne zapisanie jej przy użyciu domyślnych ustawień
biblioteki JSON (spacje po , / :, klucze w kolejności wstawiania) generuje
inne bajty i unieważnia HMAC. Zweryfikuj surową treść — lub jeśli
musisz ponownie ją serializować, dokładnie dopasuj naszą formę kanoniczną: posortowane
klucze, zwarte separatory, UTF-8.
Framework automatycznie przetwarza JSON
Oprogramowanie pośredniczące Express express.json() zużywa strumień treści
i tracisz surowe bajty. Użyj express.raw() konkretnie dla trasy webhooka
albo buforuj surową treść w oprogramowaniu pośredniczącym poprzedzającym parser.
Tak samo jest w NestJS / Koa — sprawdź ich dokumentację dotyczącą surowego ciała żądania.
Porównanie niebezpieczne czasowo
expected === signature w JS lub expected == signature w
Pythonie to porównania zależne od czasu wykonania. Użyj odpowiednio crypto.timingSafeEqual
lub hmac.compare_digest. Różnica w wydajności
jest zerowa.
Nieprawidłowy sekret dla punktów końcowych narzędzi
Bezpośrednie wywołania punktów końcowych narzędzi są podpisywane za pomocą sekretu webhooka
na poziomie organizacji (GET /v1/webhook) — nie za pomocą sekretu konkretnego punktu końcowego
z /v1/developer/webhook-endpoints. Użyj ponownie tego samego pomocnika,
ale upewnij się, że przekazujesz mu sekret organizacji na trasach narzędzi.
Ciągi zapytania w narzędziach GET/DELETE
V2 podpisuje ciąg zapytania dokładnie w wysłanej postaci. Starszy nagłówek podpisuje pustą treść, a nigdy ciąg zapytania, więc obliczanie skrótu URL dla starszego nagłówka nigdy nie da zgodnego wyniku.
Duplikaty w oknie czasowym
Znacznik czasu blokuje powtórzenia starsze niż 5 minut, a nie drugą kopię
w tym oknie. Dostarczenia do punktów końcowych odbywają się co najmniej raz, więc może również
nadejść ponowna próba tego samego zdarzenia; usuwaj duplikaty na podstawie
event_id koperty.
Brak zwracania 401 przy niezgodności
Zwracanie 200 po nieudanej weryfikacji sprawia, że procedura obsługi staje się celem ataków typu replay. Zawsze odpowiadaj kodem innym niż 2xx, jeśli weryfikacja się nie powiedzie.
Kolejne kroki
Semantyka dostarczania, ponowne próby, źródłowe adresy IP.
Zarządzaj wieloma adresami URL, rotuj sekrety.
Dwie ścieżki wywoływania narzędzi i formaty ich żądań.
Zbuduj kompletną integrację opartą na narzędziach od początku do końca.