ThunderPhone 2.0 è arrivato.Parti in autonomia, da 2¢/min.Leggi l’annuncio

Operations

Verifica le firme dei webhook

Ogni webhook e richiesta di strumenti inviata da ThunderPhone è firmata. Verifica la firma una volta con la procedura qui riportata, quindi riutilizza lo stesso controllo su ogni endpoint che esegui.

Ogni richiesta che inviamo al tuo server — consegne webhook e invocazioni di endpoint degli strumenti — include una firma HMAC-SHA256 nell'header X-ThunderPhone-Signature. Configura correttamente la verifica una volta e usa lo stesso helper in ogni handler.

L'algoritmo

  1. Leggi il corpo raw della richiesta — gli esatti byte che ti abbiamo inviato con POST.
  2. Calcola hmac_sha256(secret, body).hexdigest().
  3. Confronta in tempo costante con X-ThunderPhone-Signature. (Un semplice confronto di stringhe espone informazioni sui tempi.)

Firmiamo esattamente i byte che trasmettiamo, quindi la verifica del corpo raw funziona sempre. Quei byte corrispondono anche alla serializzazione JSON canonica del payload — chiavi ordinate alfabeticamente, separatori compatti (, e : senza spazi), UTF-8. Questo ti offre un secondo metodo del tutto equivalente quando il tuo framework espone solo JSON analizzato: serializzalo nuovamente in formato canonico e calcola l'HMAC su quello.

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

Preferisci il corpo raw — richiede un passaggio in meno ed evita le particolarità della conversione ripetuta dei numeri JSON in alcuni linguaggi.

Quale segreto?

OrigineSegreto
Endpoint webhook (/v1/developer/webhook-endpoints)secret per endpoint (48 caratteri esadecimali) restituito una sola volta alla creazione
Webhook legacy con singolo URLsecret per organizzazione restituito con GET /v1/webhook
Invocazione di endpoint degli strumenti (chiamata diretta al tuo endpoint.url)Il segreto webhook a livello di organizzazione (lo stesso del webhook legacy con singolo URL) — non un segreto per endpoint

Archivia il segreto nel tuo gestore di segreti o in una variabile d'ambiente — non inserirlo mai nel repository.

Implementazioni di riferimento

Tutte e quattro verificano il corpo raw della richiesta:

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

Configurazione specifica del framework

FastAPI
from fastapi import FastAPI, HTTPException, Request
 
app = FastAPI()
 
@app.post("/thunderphone-webhook")
async def hook(request: Request):
    body = await request.body()           # raw bytes, NOT request.json()
    sig = request.headers.get("X-ThunderPhone-Signature", "")
    if not verify(body, sig, SECRET):
        raise HTTPException(status_code=401)
 
    import json
    event = json.loads(body)
    # … dispatch on event["type"] …
    return {"ok": True}
Express
import express from "express";
 
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 sig = req.header("X-ThunderPhone-Signature") || "";
    if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
      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 JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
 
 
@csrf_exempt
@require_POST
def hook(request):
    body = request.body  # raw bytes
    sig = request.headers.get("X-ThunderPhone-Signature", "")
    if not verify(body, sig, SECRET):
        return HttpResponseForbidden("invalid signature")
    event = json.loads(body)
    # … dispatch on event["type"] …
    return JsonResponse({"ok": True})

Verifica delle chiamate agli strumenti

Quando l'agente richiama direttamente uno dei tuoi strumenti funzione (lo strumento dispone di un endpoint), la richiesta include due header ThunderPhone oltre ai tuoi endpoint.headers configurati:

  • X-ThunderPhone-Call-ID — l'ID numerico della chiamata attiva.
  • X-ThunderPhone-Signature — HMAC-SHA256, con chiave costituita dal tuo segreto webhook a livello di organizzazione, calcolato sui byte esatti del corpo della richiesta.

Lo stesso helper verify() funziona senza modifiche, con due particolarità:

  1. Gli strumenti GET / DELETE non hanno un corpo. Gli argomenti vengono passati come parametri di query e la firma viene calcolata sulla stringa di byte vuota — quindi verify(b"", sig, secret) (Python) oppure verify(Buffer.alloc(0), sig, secret) (Node). Non calcolare l'hash della stringa di query.
  2. Le organizzazioni senza un webhook legacy configurato non hanno un segreto dell'organizzazione. In questo caso le chiamate agli strumenti includono solo X-ThunderPhone-Call-ID e nessun header della firma. Configura il webhook legacy (PUT /v1/webhook) per ottenere un segreto di firma, oppure autentica le chiamate agli strumenti con un tuo header tramite endpoint.headers.
@app.post("/tools/search-appointments")
async def tool(request: Request):
    body = await request.body()  # b"" for GET/DELETE tools
    sig = request.headers.get("X-ThunderPhone-Signature", "")
    call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
    if not verify(body, sig, ORG_WEBHOOK_SECRET):
        raise HTTPException(status_code=401)
    args = json.loads(body)
    ...

L'instradamento degli strumenti in modalità webhook (strumenti senza un endpoint, inviati al webhook della tua organizzazione come telephony.tool / web.tool) è un normale webhook firmato: si applica la procedura standard descritta sopra. Consulta Strumenti funzione per entrambe le forme di richiesta.

Errori comuni

Ri-serializzazione con la formattazione predefinita

L'analisi del corpo e la sua nuova serializzazione con le impostazioni predefinite della libreria JSON (spazi dopo , / :, chiavi nell'ordine di inserimento) produce byte diversi e invalida l'HMAC. Verifica il corpo grezzo oppure, se devi ri-serializzarlo, corrispondi esattamente alla nostra forma canonica: chiavi ordinate, separatori compatti, UTF-8.

Il framework analizza automaticamente JSON

Il middleware express.json() di Express consuma il flusso del corpo e perdi i byte grezzi. Usa express.raw() specificamente sulla route webhook oppure memorizza il corpo grezzo in un pre-middleware. Lo stesso vale per NestJS / Koa: consulta la loro documentazione sul "corpo grezzo".

Confronto non sicuro rispetto ai tempi

expected === signature in JS o expected == signature in Python sono confronti con tempi variabili. Usa crypto.timingSafeEqual o hmac.compare_digest rispettivamente. La differenza di prestazioni è nulla.

Secret errato per gli endpoint degli strumenti

Le chiamate dirette agli endpoint degli strumenti sono firmate con il secret webhook a livello di organizzazione (GET /v1/webhook) — non con un secret per endpoint da /v1/developer/webhook-endpoints. Riutilizza la stessa funzione verify(), ma assicurati di fornirle il secret dell'organizzazione nelle route degli strumenti.

Hash della stringa di query negli strumenti GET/DELETE

Per i metodi degli strumenti senza corpo, la firma copre la stringa di byte vuota, mantenendo un'unica procedura universale: applica HMAC al corpo grezzo della richiesta, qualunque esso sia. L'hash dell'URL o della stringa di query non corrisponderà mai.

Mancata restituzione di 401 in caso di mancata corrispondenza

Restituire 200 quando la verifica fallisce rende l'handler un bersaglio per replay. Rispondi sempre con un codice diverso da 2xx se la verifica fallisce.


Passaggi successivi