Webhooks
Un canal webhook envoie chaque notification en POST JSON sur votre serveur HTTPS. Chaque requête est signée avec le secret du canal : vérifiez toujours la signature avant de traiter le contenu.
Pour créer le canal, voir Canaux. Le secret (whsec_…) est renvoyé une seule fois, à la création ; il est stocké chiffré par Aicscan.
Requête envoyée#
POST /aicscan HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Aicscan-Webhook/1
X-Event-Id: 0193a1c9-41aa-7b2e-9c0d-1e2f3a4b5c6d
X-Timestamp: 1794648613
X-Signature: sha256=7c1e0f6b2a…| En-tête | Contenu |
|---|---|
X-Event-Id |
Identifiant de la notification. Identique à chaque relance : sert à ignorer un doublon. |
X-Timestamp |
Heure de cet envoi, en secondes Unix (UTC). Change à chaque tentative. |
X-Signature |
sha256= suivi du HMAC-SHA256, en hexadécimal minuscule, de la chaîne "{X-Timestamp}.{corps brut}", calculé avec le secret du canal. |
Corps#
{
"id": "0193a1c9-41aa-7b2e-9c0d-1e2f3a4b5c6d",
"type": "alert",
"stage": "confirmed",
"severity": "critical",
"priority": 30,
"attempt": 1,
"timestamp": 1794648613,
"title": "🔴 CRITICAL · Treasury: large USDT outflows · outgoing",
"text": "🔴 CRITICAL · Treasury: large USDT outflows · outgoing\n✅ Confirmed · 15 confirmations\nChain: BNB Chain\nAddress: Treasury (0x8894…d4e3)\nAmount: 250,000 USDT (≈ $250,000)\nTo: 0x77cd…01e2 (unknown)\nBlock 45,123,456\nDetails: https://aicscan.io/e/0193a1c9-3e02-7f3a-9b4c-5d6e7f8091a2"
}| Champ | Sens |
|---|---|
id |
Identifiant de la notification (égal à X-Event-Id). Détail complet : GET /v1/notifications/{id}. |
type |
alert, digest, test, security, summary ou service (voir Format des alertes). |
stage |
Pour une alerte : unconfirmed, confirmed, cancelled ou escalation. Pour les autres types, reprend le type. |
severity |
critical, warning ou info. |
priority |
Priorité numérique (30 et plus : critique). |
attempt |
Numéro de la tentative (1 au premier envoi). |
timestamp |
Égal à X-Timestamp. |
title |
Première ligne du message. |
text |
Message complet, en texte brut, dans la langue du canal. |
Ignorez les champs et les valeurs que vous ne connaissez pas : de nouveaux peuvent apparaître (voir Versionnement).
Vérifier la signature#
- Lisez le corps brut, octet pour octet, avant toute analyse JSON : une analyse puis une nouvelle sérialisation change les octets et invalide la signature.
- Calculez
HMAC-SHA256(secret, X-Timestamp + "." + corps brut)et comparez"sha256=" + hexàX-Signatureen temps constant. - Rejetez la requête si
X-Timestamps’écarte de plus de 300 secondes de votre horloge. - Ignorez un
X-Event-Iddéjà traité. - Répondez
2xxen moins de 10 secondes.
Le secret s’utilise tel quel, préfixe whsec_ compris, comme clé HMAC (ses octets UTF-8).
Récepteurs complets#
Chaque exemple démarre un serveur HTTP sur le port 3000, lit le secret dans AICSCAN_WEBHOOK_SECRET et applique les cinq règles ci-dessus.
// Récepteur de webhooks Aicscan (Node.js 18+, sans dépendance).
import crypto from 'node:crypto';
import http from 'node:http';
const SECRET = process.env.AICSCAN_WEBHOOK_SECRET; // whsec_…, affiché à la création du canal
const TOLERANCE_SECONDS = 300;
const seen = new Set(); // en production : table ou cache partagé, avec expiration
export function verifySignature(secret, rawBody, timestamp, signature, now = Date.now() / 1000) {
if (typeof timestamp !== 'string' || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(typeof signature === 'string' ? signature : '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const server = http.createServer((req, res) => {
if (req.method !== 'POST') return res.writeHead(405).end();
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks); // corps brut, avant JSON.parse
const ok = verifySignature(SECRET, raw, req.headers['x-timestamp'], req.headers['x-signature']);
if (!ok) return res.writeHead(401).end();
const eventId = req.headers['x-event-id'];
if (seen.has(eventId)) return res.writeHead(200).end(); // relance déjà traitée
seen.add(eventId);
const event = JSON.parse(raw);
console.log(event.type, event.stage, event.severity, event.title);
res.writeHead(204).end(); // répondre en moins de 10 s ; traiter ensuite si besoin
});
});
server.listen(process.env.PORT ?? 3000);# Récepteur de webhooks Aicscan (Python 3.9+, bibliothèque standard seulement).
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["AICSCAN_WEBHOOK_SECRET"] # whsec_…
TOLERANCE_SECONDS = 300
seen = set() # en production : table ou cache partagé, avec expiration
def verify_signature(secret, raw_body, timestamp, signature, now=None):
if not timestamp or not timestamp.isdigit():
return False
now = time.time() if now is None else now
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest("sha256=" + mac.hexdigest(), signature or "")
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) # corps brut
if not verify_signature(SECRET, raw, self.headers.get("X-Timestamp"), self.headers.get("X-Signature")):
self.send_response(401)
self.end_headers()
return
event_id = self.headers.get("X-Event-Id")
if event_id not in seen: # une relance peut arriver deux fois
seen.add(event_id)
event = json.loads(raw)
print(event["type"], event["stage"], event["severity"], event["title"])
self.send_response(204)
self.end_headers()
if __name__ == "__main__":
HTTPServer(("", int(os.environ.get("PORT", "3000"))), Handler).serve_forever()<?php
// Récepteur de webhooks Aicscan (PHP 8+, sans dépendance).
declare(strict_types=1);
const TOLERANCE_SECONDS = 300;
function verify_signature(string $secret, string $rawBody, ?string $timestamp, ?string $signature, ?int $now = null): bool
{
if ($timestamp === null || !ctype_digit($timestamp)) {
return false;
}
$now ??= time();
if (abs($now - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature ?? '');
}
$raw = file_get_contents('php://input'); // corps brut, avant json_decode
$ok = verify_signature(
getenv('AICSCAN_WEBHOOK_SECRET'),
$raw,
$_SERVER['HTTP_X_TIMESTAMP'] ?? null,
$_SERVER['HTTP_X_SIGNATURE'] ?? null
);
if (!$ok) {
http_response_code(401);
exit;
}
$eventId = $_SERVER['HTTP_X_EVENT_ID'] ?? '';
// Ignorez un $eventId déjà traité (table avec contrainte d'unicité, par exemple).
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
error_log("{$event['type']} {$event['stage']} {$event['severity']} {$event['title']}");
http_response_code(204);// Récepteur de webhooks Aicscan (Go 1.20+, bibliothèque standard seulement).
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"sync"
"time"
)
const toleranceSeconds = 300
func verifySignature(secret string, rawBody []byte, timestamp, signature string, now time.Time) bool {
ts, err := strconv.ParseUint(timestamp, 10, 64)
if err != nil {
return false
}
if d := now.Unix() - int64(ts); d > toleranceSeconds || d < -toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
var (
mu sync.Mutex
seen = map[string]bool{} // en production : table ou cache partagé, avec expiration
)
func handler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // corps brut
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
secret := os.Getenv("AICSCAN_WEBHOOK_SECRET")
if !verifySignature(secret, raw, r.Header.Get("X-Timestamp"), r.Header.Get("X-Signature"), time.Now()) {
w.WriteHeader(http.StatusUnauthorized)
return
}
id := r.Header.Get("X-Event-Id")
mu.Lock()
dup := seen[id]
seen[id] = true
mu.Unlock()
if !dup {
var event struct{ Type, Stage, Severity, Title string }
if err := json.Unmarshal(raw, &event); err == nil {
log.Println(event.Type, event.Stage, event.Severity, event.Title)
}
}
w.WriteHeader(http.StatusNoContent)
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":"+port, nil))
}Les quatre exemples ont été exécutés (Node.js 20, Python 3, PHP 8.3, Go 1.23) contre des requêtes signées avec openssl : requête valide acceptée ; corps modifié, horodatage modifié, horodatage de plus de 5 minutes et signature absente refusés (401). Les fonctions de vérification Node.js et Python reconnaissent aussi le vecteur de référence du code d’Aicscan ci-dessous. L’exemple PHP, sans état entre deux requêtes, laisse la détection des doublons à votre base de données.
Vecteur de test#
Pour tester votre implémentation sans attendre une alerte :
| Entrée | Valeur |
|---|---|
| Secret | whsec_test |
X-Timestamp |
1700000000 |
| Corps brut | {"a":1} |
X-Signature attendue |
sha256=38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789 |
printf '1700000000.{"a":1}' | openssl dgst -sha256 -hmac whsec_test
# SHA2-256(stdin)= 38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789Pour ce vecteur, passez « maintenant » = 1700000000 à votre fonction de vérification, sinon la fenêtre de 5 minutes le rejette.
Protection contre le rejeu#
L’horodatage fait partie de la chaîne signée : un attaquant qui a intercepté une requête ne peut pas la rejouer avec un horodatage récent sans connaître le secret. En refusant tout X-Timestamp à plus de 300 secondes de votre horloge, vous bornez la fenêtre de rejeu à 5 minutes ; en mémorisant les X-Event-Id traités (au moins 24 heures, la durée des relances), vous la fermez complètement.
Gardez l’horloge de votre serveur synchronisée (NTP).
Réponse attendue et relances#
| Votre réponse | Effet |
|---|---|
2xx en moins de 10 s |
Succès. |
404 ou 410 |
Échec définitif : plus de relance pour cette notification. |
429 avec Retry-After |
Le canal attend le délai demandé avant la tentative suivante. |
3xx |
Échec : les redirections ne sont pas suivies. Donnez l’URL finale. |
| Autre statut, délai dépassé, erreur réseau | Relance. |
Les relances suivent ce calendrier : 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, puis toutes les 6 h, sans dépasser 24 heures. La réponse de votre serveur est lue sur 64 Kio au plus ; ses 200 premiers caractères sont conservés dans le journal des tentatives (GET /v1/notifications/{id}).
Répondez vite, puis traitez : placez l’alerte dans une file (base de données, file de messages) et répondez 204 ; un traitement long dépasserait les 10 secondes et provoquerait une relance.
Désactivation automatique#
Après 24 heures d’échecs continus, le canal est désactivé (state: "disabled", motif dans disabled_reason) et le propriétaire de l’espace est prévenu par email. Une fois la cause corrigée, réactivez-le :
curl -X PATCH https://aicscan.io/v1/channels/$CHANNEL_ID \
-H "Authorization: Bearer $AICSCAN_API_KEY" -H "Content-Type: application/json" \
-d '{"state":"active"}'Puis renvoyez les notifications manquées avec POST /v1/notifications/{id}/resend (liste avec GET /v1/notifications?channel_id=…&status=failed).
Sécurité de l’URL#
À la création et à chaque envoi, l’URL est contrôlée :
httpsuniquement, sans identifiants dans l’URL ;- noms refusés :
localhost,*.localhost,*.local,*.internal,*.lan,*.home.arpaet noms sans point ; - le nom est résolu au moment de l’envoi ; si une seule des adresses obtenues est privée, locale, de lien local (dont
169.254.169.254), CGNAT, réservée, multicast ou de documentation, l’envoi est refusé ; - la connexion est faite vers l’adresse vérifiée, sans seconde résolution DNS ni proxy.
Si votre récepteur filtre par adresse IP source, prévoyez que ces adresses puissent changer : fiez-vous à la signature, pas à l’adresse.