Zum Hauptinhalt springen

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​

  1. Einstellungen → Workspace → Webhooks → Webhook hinzufügen: die Payload-URL, eine optionale Beschreibung und die Ereignisse (Checkliste, gruppiert nach Aufgaben, Kommentaren, Boards, Mitgliedern, Zeiteinträgen).
  2. 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.
  3. 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​

EreignisWanndata
task.createdeine Aufgabe wird erstellt (App oder API)Task
task.updatedTitel, Beschreibung, Daten, Labels, zugewiesene Personen, Mitglieder, Checklistenpunkte abgehakt / nicht abgehaktTask (+ changes.fields)
task.movedin eine andere Liste (changes.list: {from, to}, Listennamen) oder auf ein anderes Board (changes.board, changes.key.from)Task
task.archived / task.restoredarchiviert / wiederhergestellt; task.restored auch, wenn eine Löschung rückgängig gemacht wirdTask
task.deletedgelöscht (in den Papierkorb verschoben, deleted: true)Task
task_link.added / task_link.removedeine 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.deletedKommentare und Antworten; ein wiederhergestellter Kommentar ist comment.updatedComment (+ task)
board.created / board.updated / board.archived / board.restoredBoards; updated umfasst Name, Zugriff, öffentlichen Link, Mitglieder und Listen (changes.fields)Board
member.added / member.removedjemand 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 entferntMember
time_entry.created / time_entry.updated / time_entry.deletedein Eintrag wird erfasst oder ein Timer stoppt; geändert; gelöscht (laufende Timer werden nicht gemeldet)TimeEntry (+ task)
pingnur „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" } }
}
  • id ist die Zustellungs-ID (auch X-Sol2flow-Delivery). Nutzen Sie sie, um Duplikate zu ignorieren: Zustellung erfolgt mindestens einmal.
  • data hat die Form, die die REST-API für die Ressource liefert (siehe das OpenAPI-Dokument): Task = TaskSummary plus list und deleted; Comment ohne replies, plus task: { id, key, title, board_id }; Board ohne die benutzerbezogene role; TimeEntry plus task; Member = { user: { id, name, username }, role }.
  • Datenschutz: nirgends E-Mail-Adressen; actor ist null bei Ä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": true wird 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-Eventder Name des Ereignisses
X-Sol2flow-Deliverydie Zustellungs-ID (= id der Payload)
X-Sol2flow-Webhookdie ID des Webhooks
X-Sol2flow-Signaturet=<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: und https:; kein user: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; privat 10/8, 172.16/12, 192.168/16, Unique-Local fc00::/7; Link-Local 169.254/16, fe80::/10; CGNAT 100.64/10; Dokumentations- und Benchmark-Bereiche. IPv6-Adressen, die eine IPv4-Adresse enthalten (IPv4-mapped ::ffff:…, NAT64 64:ff9b::/96, 6to4 2002::/16), werden nach dieser IPv4-Adresse beurteilt.
  • Immer abgelehnt: 0.0.0.0/8, ::, Multicast, Broadcast, 240/4 und die Cloud-Metadaten-Endpunkte 169.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 über lookup des Sockets festgelegt, mit dem ursprünglichen Hostnamen für Host und TLS/SNI). Eine gesperrte Adresse lässt die Zustellung ohne Wiederholung fehlschlagen; eine fehlgeschlagene DNS-Abfrage wird wiederholt.