Open in
Vahvista webhook-allekirjoitukset
Jokainen ThunderPhonen lähettämä webhook- ja työkalupyyntö allekirjoitetaan aikaleimalla. Vahvista se tässä olevalla esimerkillä tai SDK-aputoiminnolla ja hylkää yli viisi minuuttia vanhat uudelleenlähetetyt pyynnöt.
Jokaisessa palvelimellesi lähettämässämme pyynnössä (webhook-toimitukset, vanha yhden URL-osoitteen webhook sekä työkalupäätepisteen kutsut) on kaksi allekirjoitus- otsaketta. Molemmat ovat HMAC-SHA256-allekirjoituksia samalla salaisuudella.
| Otsake | Allekirjoittaa | Uudelleenlähetyssuojaus |
|---|---|---|
X-ThunderPhone-Signature-V2 (suositeltu) | Aikaleiman, menetelmän, polun, kyselyn ja pyynnön rungon | Hylkää yli 5 minuuttia vanhat pyynnöt |
X-ThunderPhone-Signature | Vain pyynnön rungon | Ei mitään: kaapattu pyyntö voidaan vahvistaa ikuisesti |
Vahvista X-ThunderPhone-Signature-V2 uusissa integraatioissa ja siirrä
olemassa olevat integraatiot siihen. X-ThunderPhone-Signature lähetetään edelleen
jokaisessa pyynnössä muuttumattomana, joten siihen perustuvat vahvistajat toimivat yhä. Ilmoitamme päivämäärän ennen kuin lopetamme sen lähettämisen.
Allekirjoitus V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4eton Unix-aika sekunteina, jolloin lähetimme pyynnön. Uudelleen yritetty toimitus allekirjoitetaan uudelleen lähetyshetkellä omallat-arvollaan.v1on alla olevan allekirjoitetun merkkijonon heksamuotoinen HMAC-SHA256, joka käyttää salaista avaintasi.
Arvo on pilkuilla eroteltu luettelo key=value-pareja. Ohita avaimet, joita et
tunnista, ja hyväksy pyyntö, jos jokin v1 täsmää: voimme lisätä toisen
allekirjoituksen tulevan muutoksen yhteydessä.
Allekirjoitettu merkkijono
Neljä kenttää, joista kutakin viimeistä lukuun ottamatta seuraa yksi
rivinvaihto (\n, tavu 0x0A):
<t>\n<METHOD>\n<path and query>\n<body>| Kenttä | Arvo |
|---|---|
t | Otsakkeen t-arvo |
METHOD | HTTP-metodi isoilla kirjaimilla: POST, GET, PUT, PATCH tai DELETE |
| path and query | HTTP-pyyntörivillä oleva pyynnön kohde: ensin polku, sitten ? ja kyselymerkkijono, jos sellainen on. Ei skeemaa, isäntää tai fragmenttia. Käytä sitä täsmälleen siinä muodossa kuin se saapui, purkamatta sitä tai järjestämättä parametreja uudelleen |
body | Raaka pyyntörunko. GET- ja DELETE-työkalukutsuilla ei ole runkoa, joten niiden allekirjoitettu merkkijono päättyy polun jälkeiseen rivinvaihtoon |
Vahvista näin:
- Lue ennen jäsentämistä otsake, metodi, raaka polku ja kysely sekä raaka pyyntörunko.
- Ota otsakkeesta
tja kaikkiv1-arvot. - Hylkää pyyntö, jos
ton yli 300 sekuntia ennen tai jälkeen kellosi aikaa. - Laske allekirjoitetun merkkijonon heksamuotoinen HMAC-SHA256 salaisella avaimellasi.
- Vertaa sitä
v1-arvoon vakioajassa. Vastaa401, jos mikään ei täsmää.
Esimerkki
Salainen avain whsec_example_4c1f2a9b. Lähetämme tämän pyynnön hetkellä
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"}Allekirjoitettu merkkijono koostuu näistä neljästä rivistä ilman rivinvaihtoa rungon jälkeen:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Sen HMAC-SHA256 on
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Tarkista toteutuksesi sitä vasten tai toista laskenta komennolla openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Saman sekunnin aikana tehtävällä GET-työkalukutsulla osoitteeseen
/tools/lookup?customer_id=cust_123 on tyhjä runko. Sen allekirjoitettu merkkijono on
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n ja sen allekirjoitus
on 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
SDK:iden kanssa
Python- ja TypeScript-SDK:t sisältävät apufunktion
(thunderphone 0.2.0 tai uudempi, @thunderphone/sdk 0.2.0 tai uudempi). Se
tarkistaa 5 minuutin aikarajan, vertaa vakioajassa ja hyväksyy luettelon
salaisia avaimia, joten voit kierrättää ne ilman käyttökatkoa.
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
});Ilman SDK:ta
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)
);
});
}Kehysintegraatio
Jokainen esimerkki lukee raa'an polun ja kyselymerkkijonon sekä raa'an rungon ja kutsuu sitten SDK-apufunktiota.
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) sekä ASGI:n raw_path ja query_string
(FastAPI) ovat allekirjoituksen kohde täsmälleen vastaanotetussa muodossa. Djangon
get_full_path() ja Flaskin request.path purkavat polun ensin, mikä
tuottaa tavallisille poluille saman merkkijonon. Jos päätepistepolkusi sisältää
prosenttikoodattuja tai varattuja merkkejä, kuten %2F, ; tai =, lue sen
sijaan palvelimesi tarjoama raaka kohde (esimerkiksi request.META["RAW_URI"]
Gunicornissa).
Mikä salaisuus?
Sama salaisuus allekirjoittaa molemmat otsakkeet.
| Lähde | Salaisuus |
|---|---|
Webhook-päätepiste (/v1/developer/webhook-endpoints) | Päätepistekohtainen secret (48 heksamerkkiä), joka palautetaan kerran luotaessa |
| Vanha yhden URL-osoitteen webhook | Organisaatiokohtainen secret, joka palautetaan pyynnössä GET /v1/webhook |
Työkalupäätepisteen kutsu (suora kutsu osoitteeseen endpoint.url) | Organisaatiotason webhook-salaisuus (sama kuin vanhassa yhden URL-osoitteen webhookissa) — ei päätepistekohtainen salaisuus |
Tallenna salaisuus salaisuuksien hallintaan tai ympäristömuuttujaan — älä koskaan commitoi sitä.
Työkalukutsujen varmistaminen
Kun agentti kutsuu yhtä
funktiotyökaluistasi suoraan (työkalulla on
endpoint), pyyntö sisältää seuraavat ThunderPhone-otsakkeet
määrittämiesi endpoint.headers-asetusten lisäksi:
X-ThunderPhone-Call-ID— käynnissä olevan puhelun numeerinen tunniste.X-ThunderPhone-Signature-V2— yllä oleva aikaleimattu allekirjoitus, joka on muodostettu organisaatiotason webhook-salaisuudellasi.X-ThunderPhone-Signature— vanha vain rungon kattava allekirjoitus samalla avaimella.
Kaksi asiaa poikkeaa webhookeista:
GET- /DELETE-työkaluilla ei ole runkoa. Argumentit välitetään kyselyparametreina. V2-allekirjoitus kattaa ne polun ja kyselyn kautta, ja runkokenttä on tyhjä. Vahvista käyttäen raakaa kyselymerkkijonoa: parametrien jäsentäminen ja uuden kyselymerkkijonon muodostaminen voi muuttaa niiden järjestystä tai koodausta. Vanha otsake allekirjoittaa tyhjän tavujonon.- Organisaatioilla, joille ei ole määritetty vanhaa webhookia, ei ole organisaatiosalaisuutta. Tässä
tapauksessa työkalukutsut sisältävät vain
X-ThunderPhone-Call-ID-otsakkeen eivätkä allekirjoitusotsakkeita. Määritä vanha webhook (PUT /v1/webhook) saadaksesi allekirjoitussalaisuuden tai todenna työkalukutsut omalla otsakkeellasiendpoint.headers-asetusten kautta.
@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)
...Expressissä rungon jäsennin jättää req.body-arvon tyhjäksi objektiksi GET-pyynnössä,
joten välitä Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
Webhook-tilan työkalujen välitys (työkalut ilman endpoint-asetusta,
jotka toimitetaan organisaatiosi webhookiin tyyppeinä telephony.tool /
web.tool) on tavallinen allekirjoitettu webhook — yllä oleva vakiomenettely
pätee. Katso molemmat pyyntömuodot kohdasta
Funktiotyökalut.
Vanha allekirjoitus: X-ThunderPhone-Signature
Alkuperäinen otsake on pelkän raa'an rungon heksamuotoinen HMAC-SHA256, jossa käytetään samaa salaisuutta. Siinä ei ole aikaleimaa, joten kuka tahansa yhden allekirjoitetun pyynnön kaapannut voi toistaa sen. Se lähetetään edelleen jokaisen pyynnön mukana, kun integraatiot siirtyvät V2:een.
- Lue raaka pyynnön runko — täsmälleen ne tavut, jotka lähetimme sinulle.
- Laske
hmac_sha256(secret, body).hexdigest(). - Vertaa sitä vakioaikaisesti arvoon
X-ThunderPhone-Signature.
Allekirjoitamme täsmälleen ne tavut, jotka lähetämme. Nämä tavut ovat myös
kuorman kanoninen JSON-sarjoitus — avaimet aakkosjärjestyksessä,
tiiviit erottimet (, ja : ilman välilyöntejä), UTF-8.
Jos kehys tarjoaa vain jäsennetyn JSONin, sarjoita se uudelleen kanonisesti ja
laske sille HMAC:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Suosi raakaa runkoa — siinä on yksi vaihe vähemmän, eikä se ole altis joissakin kielissä esiintyville JSON-lukujen edestakaisen muunnoksen erityispiirteille. Sama pätee V2:n runkokenttään.
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)
endYleiset sudenkuopat
Kellon ajan poikkeama
Viiden minuutin ikkuna mitataan palvelimesi kellon mukaan. Pidä se synkronoituna NTP:n avulla; yli viisi minuuttia väärässä oleva kello hylkää jokaisen pyynnön.
Välityspalvelin muuttaa polun
Allekirjoitettu polku on pyytämämme polku: ThunderPhoneen määrittämäsi URL-osoitteen polku. Jos käänteinen välityspalvelin tai API-yhdyskäytävä poistaa etuliitteen tai lisää sellaisen ennen kuin sovelluksesi näkee pyynnön, muodosta lähettämämme polku uudelleen (esimerkiksi lisää etuliite takaisin) ennen vahvistamista.
Uudelleensarjoittaminen oletusmuotoilulla
Rungon jäsentäminen ja uudelleensarjoittaminen JSON-kirjastosi
oletusasetuksilla (välilyönnit merkkien , / : jälkeen, lisäysjärjestyksessä olevat avaimet) tuottaa
erilaiset tavut ja rikkoo HMACin. Tarkista raaka runko — tai jos
sinun on sarjoitava se uudelleen, vastaa kanonista muotoamme tarkalleen: lajitellut
avaimet, tiiviit erottimet, UTF-8.
Kehys jäsentää JSONin automaattisesti
Expressin express.json()-middleware kuluttaa rungon tietovirran
ja menetät raakatavut. Käytä express.raw()-middlewarea nimenomaan webhook-reitillä
tai puskuroi raakarunko esiväliohjelmassa.
Sama koskee NestJS:ää / Koaa — tarkista niiden "raaka runko" -dokumentaatio.
Ajoitusturvaton vertailu
expected === signature JS:ssä tai expected == signature
Pythonissa ovat ajoituksesta riippuvia vertailuja. Käytä vastaavasti crypto.timingSafeEqual-
tai hmac.compare_digest-funktiota. Suorituskykyero
on olematon.
Väärä salaisuus työkalupäätepisteille
Suorat työkalupäätepistekutsut allekirjoitetaan organisaatiotason webhook-salaisuudella
(GET /v1/webhook) — ei millään päätepistekohtaisella salaisuudella,
joka on peräisin polusta /v1/developer/webhook-endpoints. Käytä samaa apufunktiota uudelleen,
mutta varmista, että välität sille organisaation salaisuuden työkalureiteillä.
Kyselymerkkijonot GET/DELETE-työkaluissa
V2 allekirjoittaa kyselymerkkijonon täsmälleen lähetetyssä muodossa. Vanha otsake allekirjoittaa tyhjän rungon, ei koskaan kyselymerkkijonoa, joten URL-osoitteen tiivistäminen vanhaa otsaketta varten ei koskaan täsmää.
Kaksoiskappaleet ikkunan sisällä
Aikaleima estää yli viisi minuuttia vanhat toistot, ei toista kopiota
tämän ikkunan sisällä. Päätepistetoimitukset tapahtuvat vähintään kerran, joten myös
saman tapahtuman uudelleenyritys voi saapua; poista kaksoiskappaleet
kuoren event_id:n perusteella.
401-vastauksen palauttamatta jättäminen, kun allekirjoitus ei täsmää
200-vastauksen palauttaminen epäonnistuneen vahvistuksen jälkeen tekee käsittelijästä uudelleenlähetyshyökkäyksen kohteen. Palauta aina muu kuin 2xx-vastaus, jos vahvistus epäonnistuu.
Seuraavat vaiheet
Toimitussemantiikka, uudelleenyritykset, lähde-IP-osoitteet.
Hallitse useita URL-osoitteita, kierrätä salaisuuksia.
Kaksi työkalukutsun polkua ja niiden pyyntömuodot.
Rakenna täydellinen työkalutuettu integraatio alusta loppuun.