Webhooks
Les webhooks d’espace de travail envoient un POST JSON signé à l’URL de votre choix quand quelque chose change dans un
espace : une tâche est créée, un commentaire ajouté, du temps saisi. Utilisez-les pour mettre à jour un autre outil sans
interroger régulièrement l’API REST.
- Qui : les admins de l’espace, dans Paramètres → Espace de travail → Webhooks, ou via l’API
(
/api/v1/workspaces/{slug}/webhooks, clé à accès complet). Au plus 20 par espace. - Réglages de l’instance (Admin → Général → API) : Autoriser les webhooks (activé par défaut ; désactivé → rien
n’est enregistré ni envoyé, la section des paramètres est verrouillée et les points de terminaison répondent 403
webhooks_disabled) et Autoriser les webhooks vers des adresses réseau privées (désactivé par défaut ; voir Adresses cibles autorisées).
Configurer un webhook
- Paramètres → Espace de travail → Webhooks → Ajouter un webhook : l’URL de destination, une description facultative et les événements (liste à cocher regroupée par tâches, commentaires, tableaux, membres, saisies de temps).
- Le secret de signature (
whsec_…) n’est affiché qu’une seule fois : enregistrez-le dans le service récepteur. sol2flow le conserve chiffré. Renouveler le secret (dans Modifier) le remplace immédiatement ; l’ancien cesse de fonctionner dès la livraison suivante. - Envoyer un test met en file un événement
ping(même quand le webhook est en pause). Livraisons affiche le journal : événement, état (Livré, Nouvel essai prévu, En attente, Échec), statut HTTP, durée, tentatives, prochain essai, les 2 premiers Ko de la réponse et le contenu, avec Renvoyer (le même contenu, sous forme de nouvelle livraison). L’interrupteur de chaque ligne met le webhook en pause ou le réactive.
Événements
| Événement | Quand | data |
|---|---|---|
task.created | une tâche est créée (application ou API) | Task |
task.updated | titre, description, dates, étiquettes, personnes assignées, membres, éléments de checklist cochés / décochés | Task (+ changes.fields) |
task.moved | vers une autre liste (changes.list: {from, to}, noms des listes), ou vers un autre tableau (changes.board, changes.key.from) | Task |
task.archived / task.restored | archivée / restaurée ; task.restored aussi quand une suppression est annulée | Task |
task.deleted | supprimée (déplacée dans la corbeille, deleted: true) | Task |
task_link.added / task_link.removed | un lien entre deux tâches est ajouté / retiré (Tâches liées) | { id, type, source, target, created_at, created_by } – type relates, blocks ou duplicates, de source vers target (chacune { id, key, title, board_id }) |
comment.created / comment.updated / comment.deleted | commentaires et réponses ; un commentaire restauré donne comment.updated | Comment (+ task) |
board.created / board.updated / board.archived / board.restored | tableaux ; updated couvre le nom, l’accès, le lien public, les membres et les listes (changes.fields) | Board |
member.added / member.removed | quelqu’un rejoint l’espace (ajouté sur la page Membres ou via l’API, accepte une invitation, ajouté depuis la boîte de dialogue Partager) ou le quitte / en est retiré | Member |
time_entry.created / time_entry.updated / time_entry.deleted | une saisie est enregistrée, ou un chrono s’arrête ; modifiée ; supprimée (les chronos en cours ne sont pas annoncés) | TimeEntry (+ task) |
ping | uniquement « Envoyer un test » / POST …/ping | { webhook: { id, url, description, events } } |
Pas (encore) annoncés : les changements d’appartenance faits par la synchronisation des groupes OIDC, les appartenances automatiques des admins de l’instance et Admin → Utilisateurs ; les paramètres de l’espace ; les pièces jointes ; les notifications.
Contenu
{
"id": "0b5a3a7e-7a0c-4d8e-9f0e-2c3f9b7e1a42",
"event": "task.moved",
"created_at": "2026-09-25T09:30:00.000Z",
"workspace": { "id": "0199…", "slug": "northwind" },
"actor": { "id": "0199…", "name": "Mara Lindqvist" },
"data": { "id": "0199…", "key": "PRD-12", "title": "Write release notes", "board_id": "…", "list_id": "…", "list": { "id": "…", "name": "Doing" }, "…": "…" },
"changes": { "list": { "from": "To do", "to": "Doing" } }
}
idest l’identifiant de la livraison (également dansX-Sol2flow-Delivery) : servez-vous-en pour ignorer les doublons, car la livraison se fait au moins une fois.dataa la forme que l’API REST renvoie pour la ressource (voir le document OpenAPI) : Task =TaskSummarypluslistetdeleted; Comment sansrepliesplustask: { id, key, title, board_id }; Board sans lerolepropre à chaque utilisateur ; TimeEntry plustask; Member ={ user: { id, name, username }, role }.- Confidentialité : aucune adresse e-mail nulle part ;
actorvautnullpour les changements faits par le système. - Les contenus de plus de 64 Ko sont réduits : les documents en texte enrichi (
description,doc) sont retirés (leur texte brut reste), les chaînes longues sont coupées et"truncated": trueest ajouté. - Le contenu est construit au moment du changement (dans sa transaction) : il montre donc l’état juste après le changement, même si la livraison est retentée des heures plus tard.
Livraison
Chaque livraison est un POST avec Content-Type: application/json, User-Agent: sol2flow-webhooks/1.0 et :
| En-tête | |
|---|---|
X-Sol2flow-Event | le nom de l’événement |
X-Sol2flow-Delivery | l’identifiant de la livraison (= id du contenu) |
X-Sol2flow-Webhook | l’identifiant du webhook |
X-Sol2flow-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)> |
- Succès : toute réponse 2xx en moins de 10 secondes. Les redirections ne sont pas suivies (un 3xx est un échec). Seuls les 2 premiers Ko de la réponse sont lus et conservés.
- Nouveaux essais après une erreur réseau, un délai dépassé, un 5xx, 408 ou 429 : après 1 min, 5 min, 30 min, 2 h,
6 h, 12 h (7 tentatives en 21 heures environ), puis la livraison passe à
failed. Les autres 4xx (et les 3xx) sont définitifs immédiatement. - Désactivation automatique : après 20 livraisons échouées d’affilée (chacune après tous ses nouveaux essais, ou définitive immédiatement), le webhook est désactivé et les admins de l’espace reçoivent une notification dans l’application. Le réactiver remet le compteur à zéro et envoie les livraisons en attente. Les pings de test ne comptent pas.
- Les webhooks en pause n’enregistrent aucun nouvel événement ; les livraisons déjà en file attendent et sont envoyées à la reprise.
- Conservation : les livraisons de plus de 30 jours sont supprimées par la tâche de nettoyage quotidienne.
Ordre et doublons
Les événements sont enregistrés avec le changement qui les a provoqués : un changement qui échoue n’envoie rien. Les
livraisons peuvent arriver plusieurs fois et, après de nouveaux essais, dans le désordre : utilisez l’id du contenu
pour ignorer les doublons et created_at pour les remettre dans l’ordre.
Vérifier la signature
Calculez le HMAC sur le corps brut de la requête (avant toute analyse JSON), comparez en temps constant et rejetez les horodatages trop anciens (rejeux).
Node.js (Express):
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.SOL2FLOW_WEBHOOK_SECRET; // whsec_…
const app = express();
app.post('/sol2flow', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Sol2flow-Signature') ?? '';
const { t, v1 } = Object.fromEntries(header.split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${req.body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
const ok = /^[0-9a-f]{64}$/.test(v1 ?? '') && crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'));
if (!ok || !fresh) return res.sendStatus(401);
const event = JSON.parse(req.body);
// … handle event.event / event.data; ignore event.id values you've seen before
res.sendStatus(204);
});
Python (Flask):
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["SOL2FLOW_WEBHOOK_SECRET"].encode() # whsec_…
app = Flask(__name__)
@app.post("/sol2flow")
def sol2flow():
parts = dict(p.split("=", 1) for p in request.headers.get("X-Sol2flow-Signature", "").split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
body = request.get_data() # raw bytes
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if not t.isdigit() or abs(time.time() - int(t)) > 300 or not hmac.compare_digest(expected, v1):
abort(401)
event = request.get_json()
# … handle event["event"], event["data"]
return "", 204
La clé est la chaîne complète du secret, whsec_ compris, en octets UTF-8.
Adresses cibles autorisées
Une URL de webhook amène le serveur à envoyer des requêtes : elle pourrait donc viser des services internes. Les règles :
- Uniquement
http:ethttps:; pas deuser:password@; 2000 caractères au maximum. - Le nom d’hôte est résolu et chaque adresse doit être autorisée ; les IP littérales sont vérifiées de la même façon.
- Refusées sauf si l’instance autorise les cibles privées : bouclage
127.0.0.0/8,::1; privées10/8,172.16/12,192.168/16, locales uniquesfc00::/7; lien local169.254/16,fe80::/10; CGNAT100.64/10; plages de documentation et de benchmark. Les adresses IPv6 qui contiennent une adresse IPv4 (IPv4-mapped::ffff:…, NAT6464:ff9b::/96, 6to42002::/16) sont jugées d’après cette adresse IPv4. - Toujours refusées :
0.0.0.0/8,::, multicast, broadcast,240/4, et les points de terminaison de métadonnées cloud169.254.169.254,169.254.170.2,100.100.100.200,fd00:ec2::254, même quand les cibles privées sont autorisées. - Vérification à l’enregistrement (422
webhook_url_blocked,webhook_url_unresolved,webhook_url_invalid) et à nouveau avant chaque tentative (DNS rebinding) : la requête est alors envoyée exactement à l’adresse vérifiée (l’IP résolue est fixée via lelookupdu socket, avec le nom d’hôte d’origine pourHostet TLS/SNI). Une adresse bloquée fait échouer la livraison sans nouvel essai ; un échec de résolution DNS est retenté.