Aller au contenu principal

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​

  1. 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).
  2. 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.
  3. 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énementQuanddata
task.createdune tâche est créée (application ou API)Task
task.updatedtitre, description, dates, étiquettes, personnes assignées, membres, éléments de checklist cochés / décochésTask (+ changes.fields)
task.movedvers une autre liste (changes.list: {from, to}, noms des listes), ou vers un autre tableau (changes.board, changes.key.from)Task
task.archived / task.restoredarchivée / restaurée ; task.restored aussi quand une suppression est annuléeTask
task.deletedsupprimée (déplacée dans la corbeille, deleted: true)Task
task_link.added / task_link.removedun 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.deletedcommentaires et réponses ; un commentaire restauré donne comment.updatedComment (+ task)
board.created / board.updated / board.archived / board.restoredtableaux ; updated couvre le nom, l’accès, le lien public, les membres et les listes (changes.fields)Board
member.added / member.removedquelqu’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.deletedune 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)
pinguniquement « 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" } }
}
  • id est l’identifiant de la livraison (également dans X-Sol2flow-Delivery) : servez-vous-en pour ignorer les doublons, car la livraison se fait au moins une fois.
  • data a la forme que l’API REST renvoie pour la ressource (voir le document OpenAPI) : Task = TaskSummary plus list et deleted ; Comment sans replies plus task: { id, key, title, board_id } ; Board sans le role propre à chaque utilisateur ; TimeEntry plus task ; Member = { user: { id, name, username }, role }.
  • Confidentialité : aucune adresse e-mail nulle part ; actor vaut null pour 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": true est 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-Eventle nom de l’événement
X-Sol2flow-Deliveryl’identifiant de la livraison (= id du contenu)
X-Sol2flow-Webhookl’identifiant du webhook
X-Sol2flow-Signaturet=<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: et https: ; pas de user: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ées 10/8, 172.16/12, 192.168/16, locales uniques fc00::/7 ; lien local 169.254/16, fe80::/10 ; CGNAT 100.64/10 ; plages de documentation et de benchmark. Les adresses IPv6 qui contiennent une adresse IPv4 (IPv4-mapped ::ffff:…, NAT64 64:ff9b::/96, 6to4 2002::/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 cloud 169.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 le lookup du socket, avec le nom d’hôte d’origine pour Host et TLS/SNI). Une adresse bloquée fait échouer la livraison sans nouvel essai ; un échec de résolution DNS est retenté.