ThunderPhone 2.0 è arrivato.Attivazione self-service, da 2¢/min.Leggi l’annuncio

Operations

Verifica le firme dei webhook

Ogni richiesta webhook e di strumento inviata da ThunderPhone è firmata con un timestamp. Verificala con la procedura o l

Ogni richiesta che inviamo al tuo server (consegne webhook, il webhook legacy a URL singolo e chiamate agli endpoint degli strumenti) contiene due intestazioni di firma. Entrambe usano HMAC-SHA256 con lo stesso segreto.

IntestazioneFirmaProtezione dai replay
X-ThunderPhone-Signature-V2 (consigliata)Timestamp, metodo, percorso, query e corpoRifiuta le richieste più vecchie di 5 minuti
X-ThunderPhone-SignatureSolo il corpoNessuna: una richiesta intercettata viene verificata per sempre

Verifica X-ThunderPhone-Signature-V2 nelle nuove integrazioni e migra quelle esistenti. X-ThunderPhone-Signature viene ancora inviato con ogni richiesta, invariato, quindi i verificatori basati su di esso continuano a funzionare. Annunceremo una data prima di smettere di inviarlo.

Firma V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t è l'ora Unix, in secondi, in cui abbiamo inviato la richiesta. Una consegna ritentata viene firmata di nuovo al momento dell'invio, con il proprio t.
  • v1 è l'HMAC-SHA256 esadecimale della stringa firmata riportata sotto, calcolato con la tua chiave segreta.

Il valore è un elenco separato da virgole di coppie key=value. Ignora le chiavi che non riconosci e accetta la richiesta se corrisponde un qualsiasi v1: potremmo aggiungere una seconda firma in una modifica futura.

La stringa firmata

Quattro campi, ciascuno seguito da un carattere di nuova riga (\n, byte 0x0A) tranne l'ultimo:

<t>\n<METHOD>\n<path and query>\n<body>
CampoValore
tIl valore t dell'header
METHODIl metodo HTTP in maiuscolo: POST, GET, PUT, PATCH o DELETE
path and queryLa destinazione della richiesta dalla riga della richiesta HTTP: il percorso, quindi ? e la stringa di query se presente. Nessuno schema, host o frammento. Usalo esattamente come è arrivato, senza decodificarlo né riordinare i parametri
bodyIl corpo grezzo della richiesta. Le chiamate agli strumenti GET e DELETE non hanno corpo, quindi la loro stringa firmata termina con la nuova riga dopo il percorso

Per verificare:

  1. Prima di qualsiasi parsing, leggi l'header, il metodo, il percorso e la query grezzi, e il corpo grezzo.
  2. Estrai t e ogni v1 dall'header.
  3. Rifiuta la richiesta se t è più di 300 secondi prima o dopo il tuo orologio.
  4. Calcola l'HMAC-SHA256 esadecimale della stringa firmata con la tua chiave segreta.
  5. Confrontalo con v1 a tempo costante. Rispondi con 401 se non corrisponde nulla.

Esempio completo

Chiave segreta whsec_example_4c1f2a9b. Inviamo questa richiesta alle 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"}

La stringa firmata è composta da queste quattro righe, senza una nuova riga dopo il corpo:

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

Il suo HMAC-SHA256 è 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Verifica la tua implementazione rispetto a questo valore oppure riproducilo con openssl:

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

Una chiamata allo strumento GET a /tools/lookup?customer_id=cust_123 nello stesso secondo ha un corpo vuoto. La sua stringa firmata è 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n e la sua firma è 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

Con gli SDK

Gli SDK Python e TypeScript includono una funzione di supporto (thunderphone 0.2.0 o successivo, @thunderphone/sdk 0.2.0 o successivo). Questa verifica la finestra di 5 minuti, confronta a tempo costante e accetta un elenco di chiavi segrete per consentirti la rotazione senza tempi di inattività.

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

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

Configurazione del framework

Ogni esempio legge il percorso e la query non elaborati e il corpo non elaborato, quindi chiama l'helper dell'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) e raw_path e query_string di ASGI (FastAPI) corrispondono esattamente al target così come ricevuto. get_full_path() di Django e request.path di Flask decodificano prima il percorso, fornendo la stessa stringa per i percorsi comuni. Se il percorso dell'endpoint contiene caratteri con codifica percentuale o riservati, come %2F, ; o =, leggi invece il target non elaborato fornito dal server (ad esempio request.META["RAW_URI"] su Gunicorn).

Quale segreto?

Lo stesso segreto firma entrambe le intestazioni.

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.

Verifica delle chiamate agli strumenti

Quando l'agente invoca direttamente uno dei tuoi strumenti funzione (lo strumento dispone di un endpoint), la richiesta include queste intestazioni ThunderPhone oltre a endpoint.headers configurato:

  • X-ThunderPhone-Call-ID — l'ID numerico della chiamata in corso.
  • X-ThunderPhone-Signature-V2 — la firma con timestamp precedente, generata con il tuo segreto webhook a livello di organizzazione.
  • X-ThunderPhone-Signature — la firma legacy relativa al solo corpo, con la stessa chiave.

Due aspetti differiscono dai webhook:

  1. Gli strumenti GET / DELETE non hanno un corpo. Gli argomenti vengono trasmessi come parametri di query. La firma V2 li include tramite il percorso e la query, mentre il campo del corpo è vuoto. Verifica usando la stringa di query non elaborata: l'analisi dei parametri e la creazione di una nuova stringa di query possono modificarne l'ordine o la codifica. L'intestazione legacy firma la stringa di byte vuota.
  2. Le organizzazioni senza un webhook legacy configurato non dispongono di un segreto dell'organizzazione. In tal caso, le chiamate agli strumenti includono solo X-ThunderPhone-Call-ID e nessuna intestazione di firma. Configura il webhook legacy (PUT /v1/webhook) per ottenere un segreto di firma oppure autentica le chiamate agli strumenti con una tua intestazione tramite 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)
    ...

In Express, un parser del corpo lascia req.body come oggetto vuoto in una richiesta GET, quindi passa Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).

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.

Firma legacy: X-ThunderPhone-Signature

L'header originale è l'HMAC-SHA256 esadecimale del solo body non elaborato, con lo stesso segreto. Non contiene alcun timestamp, quindi chiunque acquisisca una richiesta firmata può riprodurla. Viene ancora inviato con ogni richiesta mentre le integrazioni passano a V2.

  1. Leggi il body della richiesta non elaborato — gli esatti byte che ti abbiamo inviato.
  2. Calcola hmac_sha256(secret, body).hexdigest().
  3. Confronta in tempo costante con X-ThunderPhone-Signature.

Firmiamo esattamente i byte che trasmettiamo. Quei byte corrispondono anche alla serializzazione JSON canonica del payload — chiavi ordinate alfabeticamente, separatori compatti (, e : senza spazi), UTF-8. Se il tuo framework espone solo JSON analizzato, serializzalo nuovamente in modo 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 body non elaborato — è un passaggio in meno ed evita le anomalie di conversione ripetuta dei numeri JSON in alcuni linguaggi. Lo stesso vale per il campo body di 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

Errori comuni

Deriva dell'orologio

La finestra di 5 minuti viene misurata rispetto all'orologio del tuo server. Mantienilo sincronizzato con NTP; un orologio con uno scarto superiore a 5 minuti rifiuta ogni richiesta.

Un proxy riscrive il percorso

Il percorso firmato è quello richiesto da noi: il percorso dell'URL che hai configurato in ThunderPhone. Se un proxy inverso o un gateway API rimuove o aggiunge un prefisso prima che l'applicazione riceva la richiesta, ricostruisci il percorso che abbiamo inviato (ad esempio, reinserendo il prefisso) prima della verifica.

Nuova serializzazione con 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 compromette l'HMAC. Verifica il corpo non elaborato — oppure, se devi serializzarlo di nuovo, 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.

Segreto errato per gli endpoint degli strumenti

Le chiamate dirette agli endpoint degli strumenti sono firmate con il segreto webhook a livello di organizzazione (GET /v1/webhook) — non con un segreto specifico dell'endpoint da /v1/developer/webhook-endpoints. Riutilizza lo stesso helper, ma assicurati di fornirgli il segreto dell'organizzazione nelle route degli strumenti.

Stringhe di query negli strumenti GET/DELETE

V2 firma la stringa di query esattamente come viene inviata. L'header legacy firma il corpo vuoto e mai la stringa di query, quindi l'hash dell'URL per l'header legacy non corrisponderà mai.

Duplicati nella finestra temporale

Il timestamp blocca i replay più vecchi di 5 minuti, non una seconda copia all'interno di quella finestra. Le consegne agli endpoint sono almeno una volta, quindi può arrivare anche un nuovo tentativo dello stesso evento; elimina i duplicati in base a event_id dell'involucro.

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