Vérifier la signature d'un webhook (X-Camairetech-Signature) et refuser les faux appels

Oui : chaque webhook porte X-Camairetech-Signature, un HMAC-SHA256 du corps brut avec votre secret ; recalculez-le, comparez en temps constant, refusez un timestamp trop vieux et dédoublonnez sur wa_message_id.

Vérifier la signature d'un webhook (X-Camairetech-Signature) et refuser les faux appels

Astuce
Oui : chaque webhook porte X-Camairetech-Signature, un HMAC-SHA256 du corps brut avec votre secret ; recalculez-le, comparez en temps constant, refusez un timestamp trop vieux et dédoublonnez sur wa_message_id.

Scénario

Votre serveur expose une adresse publique pour recevoir les messages de vos clients. N'importe qui peut la deviner et lui envoyer un faux « message.inbound » pour déclencher un remboursement ou une réponse automatique. Oui, vous pouvez prouver que l'appel vient bien de l'API : chaque POST est signé avec un secret que vous seul connaissez.

Ce que vous recevez

POST https://votre-serveur.example/webhooks/whatsapp
Content-Type: application/json
User-Agent: CamaireTech-WhatsApp-API/1.0
X-Camairetech-Signature: sha256=3f1c…(64 caractères hexadécimaux)

{"event":"message.inbound","data":{…},"timestamp":"2026-10-30T09:14:07.123456+00:00"}

La signature est le HMAC-SHA256 des octets exacts du corps, avec votre secret comme clé, préfixé par sha256=. Le timestamp est dans le corps, donc il est couvert par la signature : un attaquant ne peut pas le rafraîchir sans casser la signature.

Où est le secret

Il est créé quand vous enregistrez l'adresse dans la console (page Connexion, Webhook). L'adresse doit être en HTTPS. Le secret n'est affiché qu'une seule fois : stockez-le tout de suite dans une variable d'environnement (GESKAP_WEBHOOK_SECRET). Perdu ? Enregistrez de nouveau l'adresse : un nouveau secret est généré et l'ancien cesse de fonctionner.

Code

Les quatre contrôles dans l'ordre : signature, fraîcheur, doublon, puis traitement.

Node.js

// Express — signature, freshness, duplicate, then your code.
import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.GESKAP_WEBHOOK_SECRET;
const MAX_AGE_MS = 5 * 60 * 1000;   // your choice: the API imposes no window
const seen = new Set();             // production: a table with a UNIQUE index on wa_message_id

// express.raw keeps the exact bytes, which is what the signature covers.
app.post("/webhooks/whatsapp", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const got = req.get("X-Camairetech-Signature") || "";
  if (got.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const { event, data, timestamp } = JSON.parse(req.body.toString("utf8"));
  if (Math.abs(Date.now() - new Date(timestamp).getTime()) > MAX_AGE_MS) {
    return res.sendStatus(401);     // replay: the timestamp is inside the signed body
  }
  res.sendStatus(200);              // answer fast: one attempt, 8 s, no retry
  const id = data.wa_message_id;
  if (id && seen.has(id)) return;   // already handled
  if (id) seen.add(id);
  if (event === "message.inbound") onInbound(data);   // your code
});

app.listen(3000);

PHP

<?php
// Signature, freshness, duplicate, then your code.
$raw = file_get_contents("php://input");   // raw bytes, before json_decode
$expected = "sha256=" . hash_hmac("sha256", $raw, getenv("GESKAP_WEBHOOK_SECRET"));
$got = $_SERVER["HTTP_X_CAMAIRETECH_SIGNATURE"] ?? "";
if (!hash_equals($expected, $got)) {
    http_response_code(401);
    exit;
}
$payload = json_decode($raw, true);
$age = abs(time() - strtotime($payload["timestamp"]));
if ($age > 300) {                          // your choice: the API imposes no window
    http_response_code(401);               // replay: the timestamp is inside the signed body
    exit;
}
http_response_code(200);                   // one attempt, 8 s, no retry
$id = $payload["data"]["wa_message_id"] ?? null;
// Production: INSERT into a table with a UNIQUE index on wa_message_id, skip on conflict.
if ($id && already_handled($id)) {
    exit;
}
if ($payload["event"] === "message.inbound") {
    on_inbound($payload["data"]);          // your code
}

Python

# Flask — signature, freshness, duplicate, then your code.
import hashlib, hmac, json, os
from datetime import datetime, timezone
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["GESKAP_WEBHOOK_SECRET"].encode()
MAX_AGE_S = 300      # your choice: the API imposes no window
seen = set()         # production: a table with a UNIQUE index on wa_message_id

@app.post("/webhooks/whatsapp")
def whatsapp_webhook():
    raw = request.get_data()   # raw bytes, before any JSON parsing
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Camairetech-Signature", "")):
        abort(401)
    payload = json.loads(raw)
    sent_at = datetime.fromisoformat(payload["timestamp"])
    if abs((datetime.now(timezone.utc) - sent_at).total_seconds()) > MAX_AGE_S:
        abort(401)             # replay: the timestamp is inside the signed body
    wamid = payload["data"].get("wa_message_id")
    if wamid and wamid in seen:
        return "", 200         # already handled
    if wamid:
        seen.add(wamid)
    if payload["event"] == "message.inbound":
        on_inbound(payload["data"])   # your code (answer fast, queue the slow work)
    return "", 200

curl

# Fake signed call to YOUR server, to test your verifier.
SECRET="$GESKAP_WEBHOOK_SECRET"
TS=$(date -u +"%Y-%m-%dT%H:%M:%S+00:00")
BODY='{"event":"message.inbound","data":{"from":"+237690000001","number":"100000000000001","wa_message_id":"wamid.TEST1","type":"text","text":"Bonjour","button_id":null},"timestamp":"'"$TS"'"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"
curl -i -X POST "https://votre-serveur.example/webhooks/whatsapp" \
  -H "Content-Type: application/json" \
  -H "X-Camairetech-Signature: $SIG" \
  --data-binary "$BODY"

Tester sans attendre un vrai message

Il n'existe pas d'événement de test envoyé par l'API. Calculez la signature vous-même et envoyez un faux appel à votre propre serveur : un appel correctement signé doit passer, une signature modifiée d'un caractère doit être refusée en 401.

Ce qu'il faut refuser

CasRéponse conseillée
En-tête absent ou signature différente401, sans rien traiter
Timestamp plus vieux que quelques minutes401 (rejeu probable)
wa_message_id déjà traité200, sans retraiter
Événement inconnu200 et ignorez-le : d'autres événements peuvent s'ajouter
Attention
Piège : vérifiez la signature sur les octets bruts reçus, avant tout JSON.parse, json_decode ou request.json(). Ré-sérialiser le JSON change les espaces, l'ordre ou les accents, et la signature ne correspond plus. Avec Express, utilisez express.raw, pas express.json.
Attention
Piège : il n'y a qu'une seule tentative par événement, avec un délai de 8 secondes, et aucune relance. Répondez 2xx vite, faites le travail ensuite, et rattrapez ce qui manque avec GET /v1/messages/inbound (voir Archiver messages et médias). Un 401 renvoyé par erreur à un vrai événement le perd.
Attention
Piège : comme il n'y a pas de relance, un événement légitime arrive toujours avec un timestamp tout frais. Refuser au-delà de quelques minutes ne coûte donc rien. La durée exacte est votre choix : l'API n'impose aucune fenêtre.

Voir ce qui a été tenté

GET /v1/logs?category=webhook (ou la page Logs de la console) liste les tentatives de message.inbound et message.echo avec le code HTTP que votre serveur a répondu. Les message.status ne sont pas tracés dans ce journal : en cas de doute, comparez avec GET /v1/messages/{id}.

Pour brancher ce gestionnaire sur un outil sans code ou un LLM, voir Brancher WhatsApp sur n8n, Zapier, un CRM ou un LLM.

Preuve — vérifié dans le code de l'API : client_webhooks.py, console_api.py, meta_webhooks.py.