Webhooks
Workspace-Webhooks senden einen signierten JSON-POST an eine URL Ihrer Wahl, wenn sich in einem Workspace etwas ändert:
eine Aufgabe wird erstellt, ein Kommentar hinzugefügt, Zeit erfasst. So halten Sie ein anderes Tool aktuell, ohne die
REST-API regelmäßig abzufragen.
- Wer: Workspace-Admins, unter Einstellungen → Workspace → Webhooks oder über die API
(
/api/v1/workspaces/{slug}/webhooks, Schlüssel mit Vollzugriff). Höchstens 20 pro Workspace. - Schalter der Instanz (Admin → Allgemein → API): Webhooks erlauben (standardmäßig an; aus: nichts wird
aufgezeichnet oder gesendet, der Einstellungsbereich ist gesperrt und die Endpunkte antworten mit 403
webhooks_disabled) und Webhooks an private Netzwerkadressen erlauben (standardmäßig aus; siehe Erlaubte Zieladressen).
Einrichtung
- Einstellungen → Workspace → Webhooks → Webhook hinzufügen: die Payload-URL, eine optionale Beschreibung und die Ereignisse (Checkliste, gruppiert nach Aufgaben, Kommentaren, Boards, Mitgliedern, Zeiteinträgen).
- Das Signatur-Secret (
whsec_…) wird nur einmal angezeigt. Hinterlegen Sie es beim empfangenden Dienst. sol2flow speichert es verschlüsselt. Secret erneuern (unter „Bearbeiten“) ersetzt es sofort; das alte funktioniert ab der nächsten Zustellung nicht mehr. - Test senden reiht ein
ping-Ereignis ein (auch wenn der Webhook pausiert ist). Zustellungen zeigt das Protokoll: Ereignis, Status (Zugestellt, Neuer Versuch geplant, Ausstehend, Fehlgeschlagen), HTTP-Status, Dauer, Versuche, nächster Versuch, die ersten 2 KB der Antwort und die Payload, mit Erneut senden (dieselbe Payload als neue Zustellung). Der Schalter in jeder Zeile pausiert den Webhook bzw. schaltet ihn wieder ein.
Ereignisse
| Ereignis | Wann | data |
|---|---|---|
task.created | eine Aufgabe wird erstellt (App oder API) | Task |
task.updated | Titel, Beschreibung, Daten, Labels, zugewiesene Personen, Mitglieder, Checklistenpunkte abgehakt / nicht abgehakt | Task (+ changes.fields) |
task.moved | in eine andere Liste (changes.list: {from, to}, Listennamen) oder auf ein anderes Board (changes.board, changes.key.from) | Task |
task.archived / task.restored | archiviert / wiederhergestellt; task.restored auch, wenn eine Löschung rückgängig gemacht wird | Task |
task.deleted | gelöscht (in den Papierkorb verschoben, deleted: true) | Task |
task_link.added / task_link.removed | eine Verknüpfung zwischen zwei Aufgaben wird hinzugefügt / entfernt (Verknüpfte Aufgaben) | { id, type, source, target, created_at, created_by } – type relates, blocks oder duplicates, von source zu target (jeweils { id, key, title, board_id }) |
comment.created / comment.updated / comment.deleted | Kommentare und Antworten; ein wiederhergestellter Kommentar ist comment.updated | Comment (+ task) |
board.created / board.updated / board.archived / board.restored | Boards; updated umfasst Name, Zugriff, öffentlichen Link, Mitglieder und Listen (changes.fields) | Board |
member.added / member.removed | jemand tritt dem Workspace bei (auf der Seite „Mitglieder“ oder über die API hinzugefügt, nimmt eine Einladung an, über den Teilen-Dialog hinzugefügt) oder verlässt ihn / wird entfernt | Member |
time_entry.created / time_entry.updated / time_entry.deleted | ein Eintrag wird erfasst oder ein Timer stoppt; geändert; gelöscht (laufende Timer werden nicht gemeldet) | TimeEntry (+ task) |
ping | nur „Test senden“ / POST …/ping | { webhook: { id, url, description, events } } |
(Noch) nicht gemeldet: Änderungen an Mitgliedschaften durch OIDC-Gruppensynchronisierung, automatische Mitgliedschaften von Instanz-Admins und Admin → Benutzer; Workspace-Einstellungen; Anhänge; Benachrichtigungen.
Payload
{
"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" } }
}
idist die Zustellungs-ID (auchX-Sol2flow-Delivery). Nutzen Sie sie, um Duplikate zu ignorieren: Zustellung erfolgt mindestens einmal.datahat die Form, die die REST-API für die Ressource liefert (siehe das OpenAPI-Dokument): Task =TaskSummarypluslistunddeleted; Comment ohnereplies, plustask: { id, key, title, board_id }; Board ohne die benutzerbezogenerole; TimeEntry plustask; Member ={ user: { id, name, username }, role }.- Datenschutz: nirgends E-Mail-Adressen;
actoristnullbei Änderungen durch das System. - Payloads über 64 KB werden gekürzt: Rich-Text-Dokumente (
description,doc) entfallen (ihr Klartext bleibt), lange Zeichenketten werden abgeschnitten und"truncated": truewird ergänzt. - Die Payload wird erzeugt, wenn die Änderung passiert (innerhalb ihrer Transaktion). Sie zeigt also den Zustand direkt nach der Änderung, auch wenn die Zustellung erst Stunden später wiederholt wird.
Zustellung
Jede Zustellung ist ein POST mit Content-Type: application/json, User-Agent: sol2flow-webhooks/1.0 und:
| Header | |
|---|---|
X-Sol2flow-Event | der Name des Ereignisses |
X-Sol2flow-Delivery | die Zustellungs-ID (= id der Payload) |
X-Sol2flow-Webhook | die ID des Webhooks |
X-Sol2flow-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)> |
- Erfolg: jede 2xx-Antwort innerhalb von 10 Sekunden. Weiterleitungen werden nicht verfolgt (ein 3xx gilt als Fehler). Nur die ersten 2 KB der Antwort werden gelesen und gespeichert.
- Wiederholungen nach einem Netzwerkfehler, Timeout, 5xx, 408 oder 429: nach 1 min, 5 min, 30 min, 2 h, 6 h, 12 h
(7 Versuche in etwa 21 Stunden), danach ist die Zustellung
failed. Andere 4xx (und 3xx) sind sofort endgültig. - Automatisches Abschalten: Nach 20 fehlgeschlagenen Zustellungen in Folge (jeweils nach allen Wiederholungen oder sofort endgültig) wird der Webhook ausgeschaltet, und die Admins des Workspace erhalten eine Benachrichtigung in der App. Wird er wieder eingeschaltet, wird der Zähler zurückgesetzt und die wartenden Zustellungen werden gesendet. Test-Pings zählen nicht.
- Pausierte Webhooks zeichnen keine neuen Ereignisse auf; bereits eingereihte Zustellungen warten und werden beim Fortsetzen gesendet.
- Aufbewahrung: Zustellungen, die älter als 30 Tage sind, löscht der tägliche Bereinigungsjob.
Reihenfolge und Duplikate
Ereignisse werden zusammen mit der Änderung aufgezeichnet, die sie ausgelöst hat: Eine fehlgeschlagene Änderung sendet
nichts. Zustellungen können mehrfach und nach Wiederholungen in anderer Reihenfolge ankommen. Nutzen Sie die id der
Payload, um Duplikate zu ignorieren, und created_at, um sie zu ordnen.
Signatur prüfen
Berechnen Sie den HMAC über den unveränderten Request-Body (vor jedem JSON-Parsing), vergleichen Sie in konstanter Zeit und lehnen Sie alte Zeitstempel ab (Replay-Angriffe).
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
Der Schlüssel ist die gesamte Secret-Zeichenkette einschließlich whsec_, als UTF-8-Bytes.
Erlaubte Zieladressen
Eine Webhook-URL lässt den Server Anfragen senden; sie könnte also auf interne Dienste zielen. Die Regeln:
- Nur
http:undhttps:; keinuser:password@; höchstens 2000 Zeichen. - Der Host wird aufgelöst, und jede Adresse muss erlaubt sein; IP-Adressen in der URL werden genauso geprüft.
- Abgelehnt, sofern die Instanz private Ziele nicht erlaubt: Loopback
127.0.0.0/8,::1; privat10/8,172.16/12,192.168/16, Unique-Localfc00::/7; Link-Local169.254/16,fe80::/10; CGNAT100.64/10; Dokumentations- und Benchmark-Bereiche. IPv6-Adressen, die eine IPv4-Adresse enthalten (IPv4-mapped::ffff:…, NAT6464:ff9b::/96, 6to42002::/16), werden nach dieser IPv4-Adresse beurteilt. - Immer abgelehnt:
0.0.0.0/8,::, Multicast, Broadcast,240/4und die Cloud-Metadaten-Endpunkte169.254.169.254,169.254.170.2,100.100.100.200,fd00:ec2::254, auch wenn private Ziele erlaubt sind. - Geprüft wird beim Speichern (422
webhook_url_blocked,webhook_url_unresolved,webhook_url_invalid) und erneut vor jedem Versuch (DNS-Rebinding): Die Anfrage geht dann genau an die geprüfte Adresse (die aufgelöste IP wird überlookupdes Sockets festgelegt, mit dem ursprünglichen Hostnamen fürHostund TLS/SNI). Eine gesperrte Adresse lässt die Zustellung ohne Wiederholung fehlschlagen; eine fehlgeschlagene DNS-Abfrage wird wiederholt.