Webhooks — signature et vérification

Un site peut avoir un webhook : à chaque événement d’alerte (scan terminé, régression, score sous le seuil, scan échoué), Optiskan envoie une requête POST à l’adresse donnée. Chaque envoi est signé : le destinataire peut vérifier qu’il vient bien de nous et qu’il n’est pas rejoué. Le webhook se crée depuis les réglages du site, dans le tableau de bord.

Ce qui est envoyé

Exemple en HTTP
POST https://votre-serveur.exemple/optiskan
Content-Type: application/json
User-Agent: Optiskan/1.0 (+https://…/scanner)
X-Optiskan-Timestamp: 1758300000
X-Optiskan-Signature: v1=3f2a…9c

{"event":"REGRESSION","site":{"name":"Mairie","url":"https://mairie.fr"},
 "scan":{"score":55,"previousScore":63,"scoreDelta":-8,"criticalIssues":2,"totalIssues":41},
 "dashboardUrl":"https://…/dashboard/sites/…","timestamp":"2026-09-19T14:00:00.000Z"}
  • X-Optiskan-Timestamp : secondes Unix au moment de l’envoi.
  • X-Optiskan-Signature : v1= suivi de l’HMAC-SHA256, en hexadécimal, de la chaîne "<timestamp>.<corps>" avec votre secret — le corps étant les octets exacts reçus, avant tout décodage JSON.
  • Les adresses Slack (hooks.slack.com) et Discord reçoivent leur format natif (blocs, embeds), signé de la même façon ; ces services ignorent les en-têtes qu’ils ne connaissent pas.

Le secret

Trente-deux octets d’aléa, rendus en hexadécimal (64 caractères), une seule fois : à la création du webhook, ou à la rotation (« Régénérer le secret »). Optiskan le garde chiffré ; il n’est jamais réaffiché. Perdu, il se régénère — mettez le nouveau chez vous avant de le régénérer, les envois suivants sont signés avec lui dès la seconde qui suit.

Vérifier

Refusez si un en-tête manque, si le timestamp s’écarte de plus de cinq minutes de votre horloge (rejeu), ou si la signature diffère. Comparez en temps constant.

Node

Exemple en JavaScript pour Node
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifier(secret, timestamp, signature, corps) {
  if (!timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const attendue = "v1=" + createHmac("sha256", secret).update(`${timestamp}.${corps}`).digest("hex");
  const a = Buffer.from(attendue), b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
// Express : app.post("/optiskan", express.raw({ type: "*/*" }), (req, res) => {
//   const ok = verifier(SECRET, req.get("X-Optiskan-Timestamp"), req.get("X-Optiskan-Signature"), req.body.toString("utf8"));
//   res.sendStatus(ok ? 200 : 401);
// });

PHP

Exemple en PHP
<?php
function verifier(string $secret, ?string $timestamp, ?string $signature, string $corps): bool {
    if ($timestamp === null || $signature === null) return false;
    if (abs(time() - (int) $timestamp) > 300) return false;
    $attendue = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $corps, $secret);
    return hash_equals($attendue, $signature);
}
$ok = verifier(SECRET,
    $_SERVER['HTTP_X_ACCESSISCAN_TIMESTAMP'] ?? null,
    $_SERVER['HTTP_X_ACCESSISCAN_SIGNATURE'] ?? null,
    file_get_contents('php://input'));
http_response_code($ok ? 200 : 401);

Livraison

  • Premier envoi dix secondes après l’événement ; en cas d’échec (réseau, réponse 5xx ou 429), trois nouvelles tentatives : 1 min, 10 min, 1 h après. Une réponse 4xx (autre que 429) n’est pas retentée : la requête a été refusée telle quelle.
  • Répondez 2xx en moins de dix secondes ; traitez ensuite. Une même livraison peut arriver deux fois si votre serveur a répondu trop tard la première fois : le corps porte timestamp, dédoublonnez dessus si besoin.
  • Dix échecs consécutifs (toutes livraisons confondues) désactivent le webhook ; le compte est prévenu (notification et courriel). Il se réactive depuis les réglages du site, une fois le destinataire réparé.
  • Les trente dernières tentatives sont visibles dans les réglages du site : événement, statut HTTP, motif, numéro de tentative.
  • Les redirections ne sont pas suivies : l’adresse déclarée reçoit la charge, pas une autre. L’adresse doit désigner un serveur public : elle est vérifiée à la création (forme, puis résolution DNS) et à chaque envoi.

API

  • GET /api/sites/{siteId}/webhook{ webhook: { id, url, actif, echecsConsecutifs, desactiveLe, createdAt, updatedAt, livraisons: [{ id, evenement, statut, erreur, tentative, reussie, date }] } | null }
  • POST /api/sites/{siteId}/webhook { url } → 201 { webhook, secret } (le secret n’est rendu qu’ici) ; 400 adresse refusée ; 409 déjà un webhook ; 503 si le serveur n’a pas de clé de chiffrement.
  • PATCH /api/sites/{siteId}/webhook { url?, actif? } { webhook } ; réactiver remet le compteur d’échecs à zéro.
  • POST /api/sites/{siteId}/webhook/secret{ secret } (rotation).
  • DELETE /api/sites/{siteId}/webhook{ success: true }.

Ces routes demandent une session. La description OpenAPI de l’API publique est servie par /api/docs.