Un webhook envoie l’activité de ton équipe vers une URL qui t’appartient, à l’instant où elle se produit. Chaque action qui arrive dans ton journal d’audit peut être poussée sous forme d’un POST JSON signé vers ton propre serveur, ton propre bot ou un outil interne : sans interrogation périodique, sans export, sans récupération de pages du panel.
Une seule règle décide de tout sur cette page. Un webhook transporte exactement ce que montre le journal d’activité de ton équipe, et rien de plus. Pas un flux parallèle avec son propre périmètre, pas une charge utile plus riche pour les machines. Si ce n’est pas sur cette page, ce n’est pas dans le corps du message.

📍 Où le trouver
Paramètres de l’équipe → Webhooks, sur la page propre à ton équipe. Jusqu’à trois destinations par équipe, chacune avec sa propre URL, ses propres catégories et son propre secret de signature.
Qui peut en configurer un : le propriétaire de l’équipe, ou un membre dont le rôle inclut Retirer un serveur de l’équipe. C’est la même règle que celle qui ouvre le journal d’activité lui-même : un webhook transmet ce que cette page affiche, donc avoir le droit de la lire, c’est avoir le droit de la transmettre. Voir Rôles et permissions d’équipe.
🎯 Ce qui est envoyé, et ce qui ne l’est jamais
Tu choisis les catégories. Seules celles que tu coches sont livrées :
| Catégorie | Ce qu’elle couvre |
|---|---|
| Fonctionnalités | Une fonctionnalité activée, désactivée, ou ses réglages enregistrés |
| Serveurs | Un serveur ajouté à l’équipe ou retiré de l’équipe |
| Équipe | Équipe renommée, rôles modifiés, webhooks changés |
| Membres | Membres invités, ajoutés, retirés ou qui partent |
| Modération | Sanctions, appels, signalements, tickets, historiques effacés |
| Abonnement | Changements d’abonnement de l’équipe |
| Facturation | Événements de facturation de l’équipe |
| Actions de la plateforme sur cette équipe | Ce qu’un administrateur de la plateforme a fait sur l’un de tes serveurs |
🚫 Trois choses ne voyagent jamais
- Les adresses IP. Dans le journal d’activité, une IP n’est montrée qu’à la personne à qui elle appartient et à personne d’autre. Un webhook n’a aucun lecteur à qui comparer, donc le champ n’est pas envoyé : il est retiré de la charge utile, pas simplement vidé.
- Les actions de back-office qui ne te concernent pas. L’administration à l’échelle de la plateforme ne porte aucune équipe, donc rien dans la diffusion ne peut l’atteindre. La catégorie Actions de la plateforme sur cette équipe ne couvre que ce qu’un administrateur a fait à tes serveurs.
- Les identifiants secrets. Tout champ qui ressemble à un jeton, une clé ou un secret arrive sous la forme
••••••••, des deux côtés d’une paire avant/après. Cela se produit une seconde fois à la sortie, indépendamment du masquage que fait la page.
Les connexions ne sont pas proposées. Le journal d’audit a une catégorie auth, volontairement absente de la liste ci-dessus : pousser des événements de connexion vers un serveur tiers, sans surveillance et indéfiniment, n’a rien à voir avec les montrer sur une page que quelqu’un a dû ouvrir. Elle pourra être ajoutée plus tard.
📦 La livraison
Chaque livraison est un seul POST avec un corps JSON :
{
"version": 1,
"delivery_id": "0a4b2f0e-8c6e-4a5f-9b3d-2f6c1e0d7a11",
"event": "server.feature.enabled",
"category": "feature",
"team": { "id": 12, "name": "Night Shift" },
"data": {
"id": 918273,
"action": "server.feature.enabled",
"category": "feature",
"description": "Enabled Starboard",
"actor_name": "Ava",
"actor_role": "moderator",
"is_admin_action": false,
"team_id": 12,
"team_name": "Night Shift",
"server_id": 4,
"server_name": "Night Shift HQ",
"subject_label": "Starboard",
"changes": { "threshold": { "before": 3, "after": 5 } },
"metadata": null,
"created_at": "2026-09-06T09:41:12+00:00"
}
}
Ces quinze champs constituent l’intégralité de data : les mêmes quinze que ceux qu’affiche le journal d’activité, moins l’adresse IP. N’importe lequel peut valoir null : une action sans serveur porte "server_id": null, une action sans différence à montrer porte "changes": null. Lis-les de façon défensive plutôt que de supposer une forme par événement.
versionest la version de l’enveloppe. Elle existe pour que ton récepteur puisse s’y adapter plutôt que de deviner quand la forme évolue.eventest l’action auditée. Il en existe plus de cent trente et la liste grandit avec la plateforme : compare sur un préfixe ou surcategory, jamais sur une liste exhaustive de noms.delivery_idreste identique à chaque nouvelle tentative du même événement. C’est ce qui rend la déduplication possible ; voir plus bas.dataest l’entrée d’audit elle-même, le même objet que celui qu’affiche le journal d’activité.
📨 En-têtes
| En-tête | Valeur |
|---|---|
User-Agent |
YAWBDB-Webhooks/1.0 |
X-YAWBDB-Event |
Le nom de l’action, identique à event dans le corps |
X-YAWBDB-Delivery |
L’identifiant de livraison, identique à delivery_id |
X-YAWBDB-Timestamp |
Heure Unix en secondes, au moment où la requête a été construite |
X-YAWBDB-Signature |
v1= suivi du condensé hexadécimal |
🔐 Vérifier la signature
Quiconque découvre l’URL de ta destination peut y envoyer n’importe quoi en POST. La signature est ce qui te permet de distinguer nos livraisons des leurs.
Le condensé est un HMAC-SHA256 calculé sur l’horodatage, un point, puis le corps brut de la requête, avec ton secret de signature comme clé :
signed_string = X-YAWBDB-Timestamp + "." + raw_body
signature = "v1=" + hex( hmac_sha256(signed_string, secret) )
Trois détails sont essentiels, et en ignorer un laisse une faille :
- Signe les octets bruts, avant tout parsing JSON. Resérialiser le corps le modifie (ordre des clés, barres obliques échappées, unicode) et le condensé ne correspondra pas.
- L’horodatage est à l’intérieur de la chaîne signée, pas seulement à côté. C’est ce qui te permet de rejeter les rejeux : refuse une livraison dont l’horodatage a plus de quelques minutes (cinq minutes est une fenêtre raisonnable) et un attaquant ne peut pas renvoyer une requête qu’il a capturée la semaine dernière. Comme l’horodatage est signé, il ne peut pas non plus le modifier.
- Compare en temps constant. Un simple
==s’arrête au premier octet différent, et cette différence de temps suffit à retrouver une signature octet par octet. Utilisehash_equals,crypto.timingSafeEqual,hmac.compare_digest, quel que soit le nom dans ton langage.
PHP
$raw = file_get_contents('php://input');
$timestamp = (int) ($_SERVER['HTTP_X_YAWBDB_TIMESTAMP'] ?? 0);
$given = $_SERVER['HTTP_X_YAWBDB_SIGNATURE'] ?? '';
if (abs(time() - $timestamp) > 300) {
http_response_code(400); // too old, or clock is wrong
exit;
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
if (!hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
http_response_code(200); // accepted
Node.js (Express)
import crypto from 'node:crypto';
// The raw body is required - express.json() alone destroys it.
app.post('/yawbdb', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = Number(req.get('X-YAWBDB-Timestamp'));
const given = req.get('X-YAWBDB-Signature') ?? '';
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);
const expected = 'v1=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.body}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(given);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
res.sendStatus(200);
});
Le préfixe v1= nomme le schéma. S’il change un jour, un récepteur qui vérifie le préfixe pourra refuser délibérément un schéma inconnu plutôt que de le mal lire en silence.
🔑 Le secret
64 caractères hexadécimaux, générés pour toi, affichés une seule fois dans le panel, juste après la création de la destination ou après un clic sur Nouveau secret. Rien ne le relit ensuite : ni la page, ni l’API, ni un export. Si tu le perds, tu en génères un nouveau.
La rotation prend effet immédiatement pour tout ce qui est envoyé à partir de ce moment. Les livraisons déjà en file continuent de partir signées avec l’ancien secret jusqu’à ce que la file se vide : prévois donc un court chevauchement et mets d’abord ton récepteur à jour.
🔁 Livraison, nouvelles tentatives et déduplication
- Seul un
2xxcompte comme reçu. Tout le reste (3xx,4xx,5xx) est un échec. Réponds vite et fais le travail ensuite. - Les redirections ne sont pas suivies. Un
302est un échec, pas un saut. Donne-nous l’URL finale. - Les délais sont courts : trois secondes pour se connecter, cinq secondes pour répondre. C’est une notification, pas un appel de procédure distant : accuse réception d’abord, traite ensuite.
- Quatre tentatives, espacées de 30 secondes, 2 minutes, puis 10 minutes : environ douze minutes de bout en bout. Cela couvre un déploiement, pas une panne.
- Les nouvelles tentatives réutilisent le même
delivery_id. Un récepteur qui traite chaquePOSTcomme nouveau va compter en double le jour où notre première tentative réussit de ton côté mais expire du nôtre. Stocke l’identifiant et ignore celui que tu as déjà traité. - L’ordre n’est pas garanti. Les livraisons sont mises en file indépendamment ; une livraison rejouée arrive après des événements survenus plus tard. Utilise
data.created_atsi l’ordre compte pour toi.
⛔ Désactivation automatique
Après 20 échecs consécutifs, la destination est désactivée et le panel l’indique sur la carte. Une destination qui a cessé de répondre a généralement cessé pour de bon, et personne ne vient nous le dire : réessayer indéfiniment coûterait une requête et une ligne d’historique par action, sans fin.
Pour la relancer : corrige ton récepteur, puis Modifie le webhook et coche de nouveau Livrer les événements à cette destination. Le réactiver est ce qui remet le compteur à zéro, donc une destination réparée n’est pas à un échec de s’éteindre de nouveau.
🧪 Envoyer un test
Envoyer un test envoie immédiatement une vraie requête signée à ta destination et t’affiche le code de statut et le temps aller-retour. Ce n’est pas une simulation : elle passe par le même code que toute vraie livraison, avec les mêmes en-têtes, donc une destination qui réussit le test ne peut pas ensuite rejeter le trafic de production à cause d’un en-tête que le test n’a jamais envoyé.
Le corps du test porte "event": "panel.webhook.test" et "category": "test", pour que ton récepteur puisse le reconnaître.
Attends-toi à deux arrivées, pas une. Appuyer sur le bouton est lui-même une action d’équipe auditée. Ta destination reçoit donc la livraison de test et, si elle est abonnée à la catégorie Équipe, une seconde livraison ordinaire pour
team.webhook.testedun instant plus tard. C’est normal, et ce n’est pas une boucle.
Un test ne compte jamais dans le plafond de 20 échecs, et ne le remet jamais à zéro. Déboguer une destination ne peut pas la désactiver.
📜 Tentatives récentes
Tentatives récentes affiche les 20 derniers essais pour cette destination : quand, quel événement, quel numéro de tentative, le code de statut ou l’erreur, et combien de temps cela a pris. Les échecs portent un court extrait de ce que ton serveur a répondu, ce qui est souvent le moyen le plus rapide de découvrir que c’est un proxy inverse, et non ton code, qui dit non.
Les tentatives sont conservées 30 jours et purgées chaque nuit. C’est un déchet de débogage, pas un registre : le journal d’audit lui-même a sa propre rétention, bien plus longue.
🛡️ Où nous refusons d’envoyer
Une URL de destination doit pointer vers l’internet public. Une URL qui se résout vers une adresse de bouclage, une plage privée, une adresse locale au lien ou une adresse de métadonnées cloud est refusée : à l’enregistrement, et de nouveau avant chaque envoi.
La seconde vérification est celle qui compte avec le temps. Un nom qui pointait vers un endroit public le jour où il a été saisi peut pointer vers 127.0.0.1 un mois plus tard, et un webhook se déclenche tant qu’il existe. Un refus compte comme un échec pour la destination et n’est pas rejoué : poser trois fois de plus la même question au résolveur ne changerait rien.
Préfère une destination en https. La signature prouve qui a envoyé le corps, elle ne le cache pas : en http simple, ton activité voyage en clair.
💬 Directement dans un salon Discord ou Slack
Colle une URL de webhook Discord (https://discord.com/api/webhooks/…) comme destination et le panel envoie un message Discord au lieu de l’enveloppe JSON : un embed par entrée, titré avec la description, avec l’événement, la catégorie, l’auteur, l’équipe, le serveur, le sujet et, quand il y en a, les changements sous forme de court diff. L’horodatage est celui de l’entrée, et le pied de page porte l’identifiant de livraison.
L’embed est construit à partir de la même enveloppe que celle décrite plus haut, il hérite donc de ses deux règles : pas d’adresse IP, identifiants masqués. Les mentions sont désactivées côté Discord : un salon ou un rôle nommé @everyone dans une entrée est publié comme du texte et ne mentionne personne. Les en-têtes X-YAWBDB-* et la signature sont toujours envoyés ; Discord les ignore simplement.
Discord répond 204 No Content à un message qu’il a accepté, ce que montre une ligne verte dans Tentatives récentes.
Il en va de même pour une URL de webhook entrant Slack (https://hooks.slack.com/services/…) : le panel publie un message Slack par entrée, avec la description en gras, puis une ligne par fait, les changements dans un bloc de code, et l’horodatage et l’identifiant de livraison en dessous. Il est construit à partir de la même enveloppe, donc les deux mêmes règles s’appliquent, et les trois caractères que Slack réserve à ses propres commandes (<, >, &) sont échappés : un nom saisi par un membre ne peut donc jamais devenir @channel ou @here. Slack répond 200 ok à un message qu’il a accepté. Les URL de workflow et de déclencheur de Slack ne sont pas des messages : elles gardent l’enveloppe JSON, qu’un workflow peut décomposer avec ses propres variables.
💡 Ce que les gens en font
- Publier l’activité de ton équipe dans un salon Discord ou Slack à toi : une URL de webhook Discord ou Slack fonctionne telle quelle, ou place ton propre récepteur entre les deux pour filtrer et reformater.
- Copier le journal vers ton propre stockage de logs et le garder au-delà de la fenêtre de rétention du panel.
- Alerter quelqu’un quand une action de modération tombe en dehors des heures de bureau.
- Déclencher une compilation, une synchronisation ou une sauvegarde quand un réglage de fonctionnalité change.
🔗 Voir aussi
- Journal d’audit : ce que le journal enregistre, et ce qu’il n’enregistre volontairement pas
- Rôles et permissions d’équipe : qui peut ouvrir la page
- Logs Serveur : le journal côté Discord, une fonctionnalité différente qui répond à une autre question