---
title: "Vérifier les signatures des webhooks"
description: "Chaque requête webhook et d’outil envoyée par ThunderPhone est signée avec un horodatage. Vérifiez-la à l’aide de la recette ou de l’assistant SDK présenté ici et rejetez les requêtes rejouées datant de plus de cinq minutes."
---

Chaque requête que nous envoyons à votre serveur (livraisons de webhooks, webhook historique à URL unique et appels aux endpoints d’outils) contient deux en-têtes de signature. Les deux utilisent HMAC-SHA256 avec le même secret.

| En-tête | Signe | Protection contre les attaques par rejeu |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (recommandé) | Horodatage, méthode, chemin, requête et corps | Rejeter les requêtes datant de plus de 5 minutes |
| `X-ThunderPhone-Signature` | Corps uniquement | Aucune : une requête capturée est vérifiée indéfiniment |

Vérifiez `X-ThunderPhone-Signature-V2` dans les nouvelles intégrations et migrez les intégrations existantes vers celui-ci. `X-ThunderPhone-Signature` est toujours envoyé avec chaque requête, sans modification, afin que les vérificateurs basés sur cet en-tête continuent de fonctionner. Nous annoncerons une date avant de cesser de l’envoyer.

## Signature V2

```http
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
```

- `t` est l'heure Unix, en secondes, à laquelle nous avons envoyé la requête. Une livraison réessayée est signée à nouveau lors de son envoi, avec son propre `t`.
- `v1` est le HMAC-SHA256 hexadécimal de la chaîne signée ci-dessous, calculé avec votre secret.

La valeur est une liste de paires `key=value` séparées par des virgules. Ignorez les clés que vous ne reconnaissez pas et acceptez la requête si un `v1` correspond : nous pourrions ajouter une seconde signature lors d'une future modification.

### La chaîne signée

Quatre champs, chacun suivi d'un saut de ligne (`\n`, octet `0x0A`), à l'exception du dernier :

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

| Champ | Valeur |
|-------|-------|
| `t` | La valeur `t` de l'en-tête |
| `METHOD` | La méthode HTTP en majuscules : `POST`, `GET`, `PUT`, `PATCH` ou `DELETE` |
| chemin et requête | La cible de la requête de la ligne de requête HTTP : le chemin, puis `?` et la chaîne de requête lorsqu'il y en a une. N'incluez ni schéma, ni hôte, ni fragment. Utilisez-la exactement telle qu'elle est arrivée, sans la décoder ni réordonner les paramètres |
| `body` | Le corps brut de la requête. Les appels d'outil `GET` et `DELETE` n'ont pas de corps ; leur chaîne signée se termine donc par le saut de ligne après le chemin |

Pour vérifier :

1. Avant toute analyse, lisez l'en-tête, la méthode, le chemin brut et la requête, ainsi que le corps brut.
2. Récupérez `t` et chaque `v1` de l'en-tête.
3. Rejetez la requête si `t` est à plus de **300 secondes** avant ou après votre horloge.
4. Calculez le HMAC-SHA256 hexadécimal de la chaîne signée avec votre secret.
5. Comparez-le à `v1` en temps constant. Répondez `401` si aucune valeur ne correspond.

### Exemple détaillé

Secret `whsec_example_4c1f2a9b`. Nous envoyons cette requête à
`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 chaîne signée correspond à ces quatre lignes, sans saut de ligne après le corps :

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

Son HMAC-SHA256 est
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Vérifiez votre implémentation avec cette valeur, ou reproduisez-la avec `openssl` :

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

Un appel d'outil `GET` vers `/tools/lookup?customer_id=cust_123` à la même
seconde a un corps vide. Sa chaîne signée est
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n` et sa signature
est `3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### Avec les SDK

Les [SDK Python et TypeScript](/api-reference/sdks) incluent un assistant
(`thunderphone` 0.2.0 ou version ultérieure, `@thunderphone/sdk` 0.2.0 ou version ultérieure). Il
vérifie la fenêtre de 5 minutes, compare en temps constant et accepte une liste
de secrets afin que vous puissiez effectuer une rotation sans interruption de service.

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

### Sans 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>

### Câblage du framework

Chaque exemple lit le chemin et la requête bruts, ainsi que le corps brut, puis appelle
l’assistant du 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), ainsi que `raw_path` et `query_string` d’ASGI
(FastAPI), correspondent exactement à la cible telle qu’elle a été reçue. La méthode
`get_full_path()` de Django et `request.path` de Flask décodent d’abord le chemin, ce qui
produit la même chaîne pour les chemins ordinaires. Si le chemin de votre point de terminaison contient
des caractères encodés en pourcentage ou réservés, tels que `%2F`, `;` ou `=`, lisez plutôt la
cible brute fournie par votre serveur (par exemple `request.META["RAW_URI"]` avec
Gunicorn).

## Quel secret ?

Le même secret signe les deux en-têtes.

| Source | Secret |
|--------|--------|
| [Point de terminaison webhook](/fr/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` par point de terminaison (48 caractères hexadécimaux), renvoyé une seule fois lors de la création |
| [Webhook hérité à URL unique](/api-reference/organizations#legacy-single-url-webhook) | `secret` par organisation renvoyé par `GET /v1/webhook` |
| [Appel de point de terminaison d’outil](/fr/tools/overview) (appel direct à votre `endpoint.url`) | Le **secret webhook au niveau de l’organisation** (le même que pour le webhook hérité à URL unique) — pas un secret par point de terminaison |

Stockez le secret dans votre gestionnaire de secrets ou une variable d’environnement — ne le validez jamais dans votre dépôt.

## Vérification des appels d'outils

Lorsque l'agent appelle directement l'un de vos
[outils de fonction](/fr/tools/overview) (l'outil possède un
`endpoint`), la requête contient ces en-têtes ThunderPhone en plus de
vos `endpoint.headers` configurés :

- `X-ThunderPhone-Call-ID` — l'identifiant numérique de l'appel en cours.
- `X-ThunderPhone-Signature-V2` — la signature horodatée ci-dessus, associée
  à votre **secret de webhook au niveau de l'organisation**.
- `X-ThunderPhone-Signature` — la signature héritée portant uniquement sur le corps, avec la même clé.

Deux éléments diffèrent des webhooks :

1. **Les outils `GET` / `DELETE` n'ont pas de corps.** Les arguments sont transmis sous forme de
   paramètres de requête. La signature V2 les couvre via le chemin et la chaîne de requête,
   et le champ du corps est vide. Vérifiez à l'aide de la chaîne de requête brute : analyser
   les paramètres et créer une nouvelle chaîne de requête peut modifier leur ordre
   ou leur encodage. L'en-tête hérité signe la chaîne d'octets vide.
2. **Les organisations sans webhook hérité configuré n'ont pas de secret d'organisation.** Dans
   ce cas, les appels d'outils ne contiennent que `X-ThunderPhone-Call-ID` et aucun
   en-tête de signature. Configurez le webhook hérité
   (`PUT /v1/webhook`) pour obtenir un secret de signature, ou authentifiez les appels
   d'outils avec votre propre en-tête via `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)
    ...
```

Dans Express, un analyseur de corps laisse `req.body` sous forme d'objet vide lors d'un `GET`,
alors transmettez `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

La distribution d'outils en **mode** webhook (outils sans `endpoint`, envoyés
à votre webhook d'organisation sous la forme `telephony.tool` / `web.tool`) est un
webhook signé classique — la procédure standard ci-dessus s'applique. Consultez
[Outils de fonction](/fr/tools/overview) pour les deux formats de requête.

## Signature héritée : `X-ThunderPhone-Signature`

L’en-tête d’origine est le HMAC-SHA256 hexadécimal du seul corps brut, avec
le même secret. Il ne contient aucun horodatage ; toute personne qui capture une
requête signée peut donc la rejouer. Il est toujours envoyé avec chaque requête pendant que
les intégrations migrent vers V2.

1. Lisez le corps de requête **brut** — les octets exacts que nous vous avons envoyés.
2. Calculez `hmac_sha256(secret, body).hexdigest()`.
3. Comparez en **temps constant** avec `X-ThunderPhone-Signature`.

Nous signons exactement les octets que nous transmettons. Ces octets correspondent également à la
**sérialisation JSON canonique** de la charge utile — clés triées par ordre
alphabétique, séparateurs compacts (`,` et `:` sans espaces), UTF-8.
Si votre framework n’expose que le JSON analysé, re-sérialisez-le de manière canonique et
calculez le HMAC de celui-ci :

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

Préférez le corps brut — cela évite une étape et les particularités de conversion aller-retour
des nombres JSON dans certains langages. Il en va de même pour le champ de corps de 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>

## Pièges courants

<AccordionGroup>
  <Accordion title="Décalage d'horloge">
    La fenêtre de 5 minutes est mesurée par rapport à l'horloge de votre serveur. Maintenez-la
    synchronisée avec NTP ; une horloge décalée de plus de 5 minutes rejette chaque
    requête.
  </Accordion>

  <Accordion title="Un proxy réécrit le chemin">
    Le chemin signé est celui que nous avons demandé : le chemin de l'URL que vous
    avez configurée dans ThunderPhone. Si un proxy inverse ou une passerelle API supprime
    ou ajoute un préfixe avant que votre application ne voie la requête, reconstruisez
    le chemin que nous avons envoyé (par exemple, remettez le préfixe) avant de vérifier.
  </Accordion>

  <Accordion title="Nouvelle sérialisation avec le formatage par défaut">
    Analyser le corps puis le sérialiser à nouveau avec les paramètres
    par défaut de votre bibliothèque JSON (espaces après `,` / `:`, clés dans l'ordre d'insertion) produit
    des octets différents et invalide le HMAC. Vérifiez le corps brut — ou, si
    vous devez le sérialiser à nouveau, reproduisez exactement notre forme canonique : clés
    triées, séparateurs compacts, UTF-8.
  </Accordion>

  <Accordion title="Le framework analyse automatiquement le JSON">
    Le middleware `express.json()` d'Express consomme le flux du corps
    et vous perdez les octets bruts. Utilisez `express.raw()` spécifiquement sur la route
    de webhook, ou mettez en mémoire tampon le corps brut dans un pré-middleware.
    Même chose avec NestJS / Koa — consultez leur documentation sur le « corps brut ».
  </Accordion>

  <Accordion title="Comparaison non sécurisée contre les attaques temporelles">
    `expected === signature` en JS ou `expected == signature` en
    Python sont des comparaisons dont la durée varie. Utilisez `crypto.timingSafeEqual`
    ou `hmac.compare_digest` respectivement. La différence de performances
    est nulle.
  </Accordion>

  <Accordion title="Mauvais secret pour les points de terminaison d'outils">
    Les appels directs aux points de terminaison d'outils sont signés avec le **secret webhook
    au niveau de l'organisation** (`GET /v1/webhook`) — et non avec un secret propre à chaque point de terminaison
    provenant de `/v1/developer/webhook-endpoints`. Réutilisez le même assistant,
    mais assurez-vous de lui fournir le secret de l'organisation sur les routes d'outils.
  </Accordion>

  <Accordion title="Chaînes de requête sur les outils GET/DELETE">
    V2 signe la chaîne de requête exactement telle qu'elle est envoyée. L'en-tête hérité signe
    le corps vide et jamais la chaîne de requête ; hacher l'URL pour
    l'en-tête hérité ne correspondra donc jamais.
  </Accordion>

  <Accordion title="Doublons dans la fenêtre">
    L'horodatage empêche les relectures datant de plus de 5 minutes, mais pas une seconde copie
    dans cette fenêtre. Les livraisons aux points de terminaison sont effectuées au moins une fois ; une
    nouvelle tentative du même événement peut donc également arriver. Dédupliquez selon le
    `event_id` de l'enveloppe.
  </Accordion>

  <Accordion title="Ne pas renvoyer 401 en cas de non-correspondance">
    Renvoyer 200 lorsque la vérification échoue fait du gestionnaire une cible de rejeu.
    Répondez toujours avec un code autre que 2xx si la vérification échoue.
  </Accordion>
</AccordionGroup>

---

## Étapes suivantes

<CardGroup cols={2}>
  <Card title="Vue d'ensemble des webhooks" icon="bolt" href="/fr/webhooks/overview">
    Sémantique de livraison, nouvelles tentatives, adresses IP sources.
  </Card>
  <Card title="Points de terminaison de webhook" icon="plug" href="/fr/webhooks/endpoints">
    Gérez plusieurs URL, effectuez la rotation des secrets.
  </Card>
  <Card title="Outils de fonction" icon="screwdriver-wrench" href="/fr/tools/overview">
    Les deux chemins d'invocation d'outils et la structure de leurs requêtes.
  </Card>
  <Card title="Intégrations d'outils" icon="wrench" href="/fr/guides/build-tool-integration">
    Créez une intégration complète basée sur des outils de bout en bout.
  </Card>
</CardGroup>
