ThunderPhone 2.0 je tu.Začnite sami, už od 2 ¢/min.Prečítať oznámenie

Operations

Overenie podpisov webhookov

Každý webhook a každá požiadavka nástroja, ktoré ThunderPhone odosiela, sú podpísané časovou pečiatkou. Overte ich pomocou postupu alebo pomocníka SDK uvedeného tu a odmietnite opätovne prehrané požiadavky staršie ako päť minút.

Každá požiadavka, ktorú odosielame na váš server (doručenia webhookov, starší webhook s jednou adresou URL a volania koncových bodov nástrojov), obsahuje dve hlavičky podpisu. Obe používajú HMAC-SHA256 s rovnakým tajomstvom.

HlavičkaPodpisujeOchrana pred opakovaním
X-ThunderPhone-Signature-V2 (odporúčané)Časová pečiatka, metóda, cesta, dopyt a teloOdmietnite požiadavky staršie ako 5 minút
X-ThunderPhone-SignatureLen teloŽiadna: zachytená požiadavka sa overí navždy

V nových integráciách overujte X-ThunderPhone-Signature-V2 a existujúce integrácie naň presuňte. X-ThunderPhone-Signature sa stále odosiela pri každej požiadavke bez zmeny, takže overovače založené na ňom naďalej fungujú. Dátum oznámime predtým, než ho prestaneme odosielať.

Podpis V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t je unixový čas v sekundách, keď sme odoslali požiadavku. Opakované doručenie sa pri odoslaní znova podpíše s vlastným t.
  • v1 je hexadecimálny HMAC-SHA256 podpísaného reťazca uvedeného nižšie, vytvorený pomocou vášho tajného kľúča.

Hodnota je zoznam dvojíc key=value oddelených čiarkami. Ignorujte kľúče, ktoré nerozpoznávate, a prijmite požiadavku, ak sa zhoduje ľubovoľné v1: pri budúcej zmene môžeme pridať druhý podpis.

Podpísaný reťazec

Štyri polia, pričom po každom nasleduje jeden nový riadok (\n, bajt 0x0A) okrem posledného:

<t>\n<METHOD>\n<path and query>\n<body>
PoleHodnota
tHodnota t z hlavičky
METHODMetóda HTTP veľkými písmenami: POST, GET, PUT, PATCH alebo DELETE
path and queryCieľ požiadavky z riadku požiadavky HTTP: cesta, potom ? a reťazec dopytu, ak je k dispozícii. Bez schémy, hostiteľa alebo fragmentu. Použite ho presne tak, ako prišiel, bez dekódovania alebo zmeny poradia parametrov
bodyNespracované telo požiadavky. Volania nástroja GET a DELETE nemajú telo, preto ich podpísaný reťazec končí novým riadkom za cestou

Overenie:

  1. Pred akýmkoľvek spracovaním načítajte hlavičku, metódu, nespracovanú cestu a dopyt a nespracované telo.
  2. Z hlavičky získajte t a každé v1.
  3. Odmietnite požiadavku, ak je t viac než 300 sekúnd pred alebo po čase na vašich hodinách.
  4. Vypočítajte hexadecimálny HMAC-SHA256 podpísaného reťazca pomocou vášho tajného kľúča.
  5. Porovnajte ho s v1 v konštantnom čase. Ak sa nič nezhoduje, odpovedzte 401.

Príklad

Tajný kľúč whsec_example_4c1f2a9b. Túto požiadavku odosielame v čase 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"}

Podpísaný reťazec sú tieto štyri riadky bez nového riadka za telom:

1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}

Jeho HMAC-SHA256 je 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Overte podľa neho svoju implementáciu alebo ho reprodukujte pomocou openssl:

printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'

Volanie nástroja GET na /tools/lookup?customer_id=cust_123 v tej istej sekunde má prázdne telo. Jeho podpísaný reťazec je 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n a jeho podpis je 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

So súpravami SDK

Súpravy SDK pre Python a TypeScript obsahujú pomocnú funkciu (thunderphone 0.2.0 alebo novší, @thunderphone/sdk 0.2.0 alebo novší). Kontroluje 5-minútové okno, porovnáva v konštantnom čase a prijíma zoznam tajných kľúčov, aby ste ich mohli rotovať bez výpadku.

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
});

Bez SDK

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)
    );
  });
}

Prepojenie s frameworkami

Každý príklad načíta nespracovanú cestu a query reťazec aj nespracované telo a potom zavolá pomocnú funkciu SDK.

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) a ASGI raw_path a query_string (FastAPI) predstavujú cieľ presne tak, ako bol prijatý. Funkcie Django get_full_path() a Flask request.path najprv dekódujú cestu, čo pri bežných cestách poskytne rovnaký reťazec. Ak cesta vášho endpointu obsahuje percentuálne kódované alebo vyhradené znaky, napríklad %2F, ; alebo =, načítajte namiesto toho nespracovaný cieľ, ktorý poskytuje váš server (napríklad request.META["RAW_URI"] v Gunicorne).

Ktorý tajný kľúč?

Rovnaké tajomstvo podpisuje obe hlavičky.

ZdrojTajný kľúč
Koncový bod webhooku (/v1/developer/webhook-endpoints)secret pre konkrétny koncový bod (48 hexadecimálnych znakov), vrátený iba pri vytvorení
Starší webhook s jednou URLsecret pre organizáciu vrátený pri GET /v1/webhook
Volanie koncového bodu nástroja (priame volanie vášho endpoint.url)Tajný kľúč webhooku na úrovni organizácie (rovnaký ako pre starší webhook s jednou URL) — nie tajný kľúč pre konkrétny koncový bod

Tajný kľúč uložte do správcu tajných údajov alebo premennej prostredia — nikdy ho neukladajte do repozitára.

Overovanie volaní nástrojov

Keď hlasový agent priamo vyvolá jeden z vašich funkčných nástrojov (nástroj má endpoint), požiadavka okrem vášho nakonfigurovaného endpoint.headers obsahuje aj tieto hlavičky ThunderPhone:

  • X-ThunderPhone-Call-ID — číselné ID prebiehajúceho hovoru.
  • X-ThunderPhone-Signature-V2 — vyššie uvedený podpis s časovou pečiatkou, vytvorený pomocou vášho tajomstva webhooku na úrovni organizácie.
  • X-ThunderPhone-Signature — starší podpis iba tela s rovnakým kľúčom.

Oproti webhookom sa líšia dve veci:

  1. Nástroje GET / DELETE nemajú telo. Argumenty sa prenášajú ako parametre dotazu. Podpis V2 ich zahŕňa prostredníctvom cesty a dotazu a pole tela je prázdne. Overujte pomocou nespracovaného reťazca dotazu: spracovanie parametrov a vytvorenie nového reťazca dotazu môže zmeniť ich poradie alebo kódovanie. Staršia hlavička podpisuje prázdny reťazec bajtov.
  2. Organizácie bez nakonfigurovaného staršieho webhooku nemajú tajomstvo organizácie. V takom prípade volania nástrojov obsahujú iba X-ThunderPhone-Call-ID a žiadne hlavičky podpisu. Nakonfigurujte starší webhook (PUT /v1/webhook), aby ste získali tajomstvo na podpisovanie, alebo overujte volania nástrojov vlastnou hlavičkou cez endpoint.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)
    ...

V Express parser tela ponechá req.body pri požiadavke GET ako prázdny objekt, preto odovzdajte Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).

Dispečovanie nástrojov v režime webhooku (nástroje bez endpoint, doručené do webhooku vašej organizácie ako telephony.tool / web.tool) je bežný podpísaný webhook — platí štandardný postup uvedený vyššie. Pre oba tvary požiadaviek si pozrite Funkčné nástroje.

Starší podpis: X-ThunderPhone-Signature

Pôvodná hlavička je hexadecimálny HMAC-SHA256 iba nespracovaného tela s tým istým tajomstvom. Neobsahuje časovú pečiatku, takže ktokoľvek, kto zachytí jednu podpísanú požiadavku, ju môže znova odoslať. Počas prechodu integrácií na V2 sa stále odosiela pri každej požiadavke.

  1. Prečítajte nespracované telo požiadavky — presné bajty, ktoré sme vám odoslali.
  2. Vypočítajte hmac_sha256(secret, body).hexdigest().
  3. Porovnajte v konštantnom čase s X-ThunderPhone-Signature.

Podpisujeme presne tie bajty, ktoré prenášame. Tieto bajty sú zároveň kanonickou serializáciou JSON obsahu — kľúče zoradené abecedne, kompaktné oddeľovače (, a : bez medzier), UTF-8. Ak váš framework sprístupňuje iba spracovaný JSON, znova ho kanonicky serializujte a vypočítajte z neho HMAC:

# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")

Uprednostnite nespracované telo — je to o jeden krok menej a je odolné voči zvláštnostiam opakovaného prevodu čísel JSON v niektorých jazykoch. To isté platí pre pole tela vo V2.

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

Bežné úskalia

Časový posun hodín

5-minútové okno sa meria podľa hodín vášho servera. Udržiavajte ich synchronizované s NTP; hodiny oneskorené alebo predbiehajúce o viac než 5 minút odmietnu každú požiadavku.

Proxy prepíše cestu

Podpísaná cesta je tá, ktorú sme požadovali: cesta adresy URL, ktorú ste nakonfigurovali v ThunderPhone. Ak reverzné proxy alebo brána API odstráni alebo pridá predponu predtým, než vaša aplikácia uvidí požiadavku, pred overením znova vytvorte cestu, ktorú sme odoslali (napríklad pridajte predponu späť).

Opätovná serializácia s predvoleným formátovaním

Spracovanie tela a jeho opätovné vypísanie s predvolenými nastaveniami vašej knižnice JSON (medzery za , / :, kľúče v poradí vloženia) vytvorí odlišné bajty a naruší HMAC. Overte nespracované telo — alebo ak ho musíte znovu serializovať, presne dodržte náš kanonický formát: zoradené kľúče, kompaktné oddeľovače, UTF-8.

Framework automaticky spracúva JSON

Middleware express.json() v Express spotrebuje stream tela a stratíte nespracované bajty. Použite express.raw() konkrétne na trase webhooku alebo uložte nespracované telo do vyrovnávacej pamäte v predbežnom middleware. To isté platí pre NestJS / Koa — pozrite si ich dokumentáciu k „raw body“.

Porovnanie nebezpečné z hľadiska časovania

expected === signature v JS alebo expected == signature v Pythone sú porovnania s premenlivým časovaním. Použite príslušne crypto.timingSafeEqual alebo hmac.compare_digest. Rozdiel vo výkone je zanedbateľný.

Nesprávne tajomstvo pre koncové body nástrojov

Priame volania koncových bodov nástrojov sú podpísané pomocou webhookového tajomstva na úrovni organizácie (GET /v1/webhook) — nie tajomstvom konkrétneho koncového bodu z /v1/developer/webhook-endpoints. Použite opätovne rovnakého pomocníka, ale uistite sa, že mu na trasách nástrojov odovzdáte tajomstvo organizácie.

Parametre dopytu v nástrojoch GET/DELETE

V2 podpisuje reťazec parametrov dopytu presne tak, ako bol odoslaný. Staršia hlavička podpisuje prázdne telo a nikdy nie reťazec parametrov dopytu, takže hashovanie adresy URL pre staršiu hlavičku sa nikdy nebude zhodovať.

Duplikáty v rámci okna

Časová pečiatka zabraňuje opakovaniam starším než 5 minút, nie druhej kópii v rámci tohto okna. Doručenia na koncový bod prebiehajú aspoň raz, takže môže prísť aj opakovaný pokus o doručenie rovnakej udalosti; odstráňte duplicity podľa event_id obálky.

Nevrátenie 401 pri nezhode

Vrátenie 200 pri neúspešnom overení robí z obsluhy cieľ opakovaných útokov. Ak overenie zlyhá, vždy odpovedzte stavom iným než 2xx.


Ďalšie kroky