---
title: "Verifica le firme dei webhook"
description: "Ogni richiesta webhook e di strumento inviata da ThunderPhone è firmata con un timestamp. Verificala con la procedura o l'helper SDK qui indicato e rifiuta le richieste riprodotte con più di cinque minuti."
---

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.

| Intestazione | Firma | Protezione dai replay |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (consigliata) | Timestamp, metodo, percorso, query e corpo | Rifiuta le richieste più vecchie di 5 minuti |
| `X-ThunderPhone-Signature` | Solo il corpo | Nessuna: 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

```http
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:

```text
<t>\n<METHOD>\n<path and query>\n<body>
```

| Campo | Valore |
|-------|-------|
| `t` | Il valore `t` dell'header |
| `METHOD` | Il metodo HTTP in maiuscolo: `POST`, `GET`, `PUT`, `PATCH` o `DELETE` |
| path and query | La 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 |
| `body` | Il 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):

```http
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:

```text
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`:

```bash
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](/api-reference/sdks) 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à.

<CodeGroup>
```python 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
)
```

```javascript 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
});
```
</CodeGroup>

### Senza un SDK

<CodeGroup>
```python 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)
```

```javascript 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)
    );
  });
}
```
</CodeGroup>

### Configurazione del framework

Ogni esempio legge il percorso e la query non elaborati e il corpo non elaborato, quindi chiama l'helper dell'SDK.

<CodeGroup>
```python 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}
```

```javascript 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);
  },
);
```

```python 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})
```
</CodeGroup>

`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.

| Origine | Segreto |
|--------|--------|
| [Endpoint webhook](/it/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` per endpoint (48 caratteri esadecimali) restituito una sola volta alla creazione |
| [Webhook legacy con singolo URL](/api-reference/organizations#legacy-single-url-webhook) | `secret` per organizzazione restituito con `GET /v1/webhook` |
| [Invocazione di endpoint degli strumenti](/it/tools/overview) (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](/it/tools/overview) (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`.

```python
@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](/it/tools/overview) 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:

```python
# 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.

<CodeGroup>
```python 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 "")
```

```javascript 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 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 Ruby
require "openssl"

def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end
```
</CodeGroup>

## Errori comuni

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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".
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

---

## Passaggi successivi

<CardGroup cols={2}>
  <Card title="Panoramica dei webhook" icon="bolt" href="/it/webhooks/overview">
    Semantica di consegna, tentativi, IP di origine.
  </Card>
  <Card title="Endpoint webhook" icon="plug" href="/it/webhooks/endpoints">
    Gestisci più URL, ruota i secret.
  </Card>
  <Card title="Function Tools" icon="screwdriver-wrench" href="/it/tools/overview">
    I due percorsi di invocazione degli strumenti e le relative forme di richiesta.
  </Card>
  <Card title="Integrazioni degli strumenti" icon="wrench" href="/it/guides/build-tool-integration">
    Crea un'integrazione completa basata su strumenti dall'inizio alla fine.
  </Card>
</CardGroup>
