Vérifier les webhooks d'alerte subnera : contrôler la signature en Node.js ou Python
Vérifié avec T@H 3.7.0 sur macOS 26.6.2 (8 octobre 2026)
Publié le 12 octobre 2026 · 7 min de lecture
Un canal d'alerte webhook envoie ses messages à une URL que vous avez choisie. Quiconque connaît cette URL pourrait aussi y écrire : un récepteur qui agit sur le message (prévenir quelqu'un, ouvrir un ticket, relancer un service) doit donc vérifier que la requête vient bien de subnera hub. Chaque envoi webhook est signé dans ce but. Ce guide explique ce qui est envoyé, comment calculer le contrôle, et donne du code qui fonctionne en Node.js et en Python. Si vous n'avez pas encore de canal webhook, commencez par le guide des alertes.
Les canaux webhook sont disponibles avec les offres Team et Fleet.
D'où vient le secret
Chaque canal webhook a son propre secret de signature, une chaîne qui commence par subnera_whs_.
- Un nouveau canal webhook affiche son secret une seule fois, juste après sa création. Copiez-le alors dans la configuration de votre récepteur ; subnera hub ne le réaffiche jamais.
- Renouveler le secret, sur la ligne du canal, génère un nouveau secret, affiché une seule fois. Le changement est immédiat : l'ancien secret cesse de signer aussitôt, mettez donc votre récepteur à jour tout de suite. Il n'y a pas de période de chevauchement.
- Les canaux webhook créés avant l'arrivée de la signature affichent Non signé. Leurs envois ne portent aucune signature tant que quelqu'un n'a pas utilisé Activer la signature sur la ligne, ce qui affiche aussi le nouveau secret une seule fois.
Traitez le secret comme un mot de passe : gardez-le dans le coffre de secrets de votre récepteur, pas dans le code source. Si un administrateur qui le connaissait quitte le pool, renouvelez-le.
Ce qu'envoie subnera hub
Chaque alerte est une requête HTTP POST dont le corps JSON contient un seul champ :
{"text": "Mac-mini: stuck firing — training but no throughput for 15 min (working, L12)"}
et trois en-têtes ajoutés pour la signature :
| En-tête | Valeur |
|---|---|
X-Subnera-Timestamp |
L'heure d'envoi, en secondes Unix (chiffres uniquement). |
X-Subnera-Delivery |
Un UUID aléatoire, nouveau à chaque envoi. |
X-Subnera-Signature |
v1= suivi de 64 caractères hexadécimaux en minuscules. |
Le récepteur doit répondre par un statut 2xx. Tout autre statut compte comme un envoi échoué sur la page Alertes.
Comment la signature est calculée
La signature est un HMAC avec SHA-256 calculé sur cette chaîne exacte :
<timestamp>.<deliveryId>.<corps brut>
c'est-à-dire l'en-tête d'horodatage, un point, l'en-tête de livraison, un point, puis le corps exactement tel qu'il est arrivé. La clé est la chaîne complète du secret, en octets UTF-8, préfixe subnera_whs_ compris. Ne retirez pas le préfixe et ne décodez pas le reste en base64. Le résultat s'écrit en hexadécimal minuscule, précédé de v1=.
Ce qu'il faut vérifier, dans l'ordre
- Lire les en-têtes sans tenir compte de la casse. Beaucoup de serveurs passent les noms d'en-têtes en minuscules (
x-subnera-signature). - Utiliser les octets bruts de la requête. Calculez le HMAC sur le corps tel qu'il est reçu. Si votre framework analyse le JSON puis que vous le resérialisez, les espaces et l'ordre des clés peuvent changer et le contrôle échoue. Capturez le corps brut avant toute analyse.
- Comparer à temps constant. Utilisez la comparaison à temps constant de votre langage, jamais
==sur les deux chaînes. - Rejeter les horodatages périmés. Si l'horodatage s'écarte de plus de 300 secondes de votre propre horloge (dans un sens comme dans l'autre), refusez la requête.
- Écarter les rejeux. Mémorisez chaque valeur
X-Subnera-Deliveryacceptée pendant au moins cette même fenêtre et refusez un identifiant de livraison déjà vu. Ne le faites qu'après la vérification de la signature, pour qu'une requête falsifiée ne puisse pas remplir votre mémoire.
Au moindre échec, répondez 401 et n'agissez pas sur le message. N'écrivez jamais le secret dans les journaux.
Node.js
Ce code n'utilise que les modules intégrés de Node. Il lit le corps brut dans un Buffer avant d'y toucher.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SECRET = process.env.SUBNERA_WEBHOOK_SECRET; // subnera_whs_...
const TOLERANCE_S = 300;
const seen = new Map(); // delivery id -> expiry (ms)
export function verifySubneraWebhook(secret, headers, rawBody, nowS = Math.floor(Date.now() / 1000)) {
const timestamp = headers['x-subnera-timestamp'];
const deliveryId = headers['x-subnera-delivery'];
const signature = headers['x-subnera-signature'];
if (!timestamp || !deliveryId || !signature) return false;
if (!/^\d{1,15}$/.test(timestamp) || !/^[A-Za-z0-9-]{1,64}$/.test(deliveryId)) return false;
if (Math.abs(nowS - Number(timestamp)) > TOLERANCE_S) return false;
if (!/^v1=[0-9a-f]{64}$/.test(signature)) return false;
const expected = createHmac('sha256', secret) // the whole secret string, as UTF-8
.update(`${timestamp}.${deliveryId}.`)
.update(rawBody) // a Buffer: the bytes as received
.digest();
const given = Buffer.from(signature.slice(3), 'hex');
if (!timingSafeEqual(expected, given)) return false;
// Replay protection: only after the signature is known good.
const nowMs = nowS * 1000;
for (const [id, expiry] of seen) if (expiry <= nowMs) seen.delete(id);
if (seen.has(deliveryId)) return false;
seen.set(deliveryId, nowMs + TOLERANCE_S * 1000);
return true;
}
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (req.method !== 'POST' || !verifySubneraWebhook(SECRET, req.headers, rawBody)) {
res.writeHead(401).end();
return;
}
const { text } = JSON.parse(rawBody.toString('utf8'));
console.log('subnera alert:', text);
res.writeHead(204).end();
});
}).listen(8080);
Avec Express, montez express.raw({ type: 'application/json' }) sur la route à la place de express.json() : req.body est alors le Buffer brut, et req.headers est déjà en minuscules.
Python
Ce code utilise la bibliothèque standard pour le contrôle et Flask pour la route d'exemple. request.get_data() renvoie les octets bruts, et les en-têtes de Flask ne sont pas sensibles à la casse.
import hashlib
import hmac
import os
import re
import time
from flask import Flask, abort, request
SECRET = os.environ["SUBNERA_WEBHOOK_SECRET"] # subnera_whs_...
TOLERANCE_S = 300
seen = {} # delivery id -> expiry (unix seconds)
def verify_subnera_webhook(secret, headers, raw_body, now=None):
now = int(time.time()) if now is None else now
timestamp = headers.get("X-Subnera-Timestamp")
delivery_id = headers.get("X-Subnera-Delivery")
signature = headers.get("X-Subnera-Signature")
if not (timestamp and delivery_id and signature):
return False
if not re.fullmatch(r"\d{1,15}", timestamp) or not re.fullmatch(r"[A-Za-z0-9-]{1,64}", delivery_id):
return False
if abs(now - int(timestamp)) > TOLERANCE_S:
return False
if not re.fullmatch(r"v1=[0-9a-f]{64}", signature):
return False
signed = f"{timestamp}.{delivery_id}.".encode() + raw_body # raw_body: bytes as received
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature[3:]):
return False
# Replay protection: only after the signature is known good.
for known, expiry in list(seen.items()):
if expiry <= now:
del seen[known]
if delivery_id in seen:
return False
seen[delivery_id] = now + TOLERANCE_S
return True
app = Flask(__name__)
@app.post("/subnera")
def subnera_alert():
if not verify_subnera_webhook(SECRET, request.headers, request.get_data()):
abort(401)
print("subnera alert:", request.get_json()["text"])
return "", 204
Les deux extraits gardent la mémoire anti-rejeu dans le processus. Si votre récepteur tourne en plusieurs processus ou redémarre souvent, conservez les identifiants de livraison déjà vus dans un stockage partagé, par exemple Redis, avec une expiration de 300 secondes.
Tester votre récepteur
Cliquez sur Envoyer un test sur la ligne du canal : le message de test est signé comme n'importe quel autre envoi. Une requête construite à la main doit échouer : modifiez un caractère du corps, ou envoyez deux fois la même livraison, et votre récepteur doit répondre 401 dans les deux cas.
Si toutes les requêtes échouent, les causes habituelles sont, dans l'ordre : un secret copié sans son préfixe subnera_whs_ (ou avec une espace en fin de chaîne), un corps analysé puis resérialisé avant le contrôle, un framework qui décode le corps en texte avec un autre jeu de caractères, et une horloge de serveur qui dérive de plus de cinq minutes.
subnera n'est pas officiel et n'est affilié ni à IOTA, ni à Macrocosmos, ni à Bittensor. Pour les règles d'alerte et les autres types de canal, voir le guide des alertes ; pour savoir qui peut gérer les canaux, voir pools et rôles.
Guides associés
Envie de voir ça sur votre propre Mac ?
subnera affiche la position réelle dans la file et la phase dans votre barre de menu.
Installer subnera