Webhooks
Workspace webhooks send a signed JSON POST to a URL of your choice when something changes in a workspace — a task is
created, a comment added, time logged. Use them to update another tool without polling the REST API.
- Who: workspace admins, in Settings → Workspace → Webhooks, or over the API
(
/api/v1/workspaces/{slug}/webhooks, full-access key). At most 20 per workspace. - Instance switches (Admin → General → API): Allow webhooks (default on; off → nothing is recorded or sent, the
settings section is locked and the endpoints answer 403
webhooks_disabled) and Allow webhooks to private network addresses (default off; see Allowed target addresses).
Setting one up
- Settings → Workspace → Webhooks → Add webhook: the payload URL, an optional description, and the events (checklist grouped by tasks, comments, boards, members, time entries).
- The signing secret (
whsec_…) is shown once — store it with the receiving service. sol2flow keeps it encrypted. Rotate secret (in Edit) replaces it at once; the old one stops working for the next delivery. - Send test queues a
pingevent (also while the webhook is paused). Deliveries shows the log: event, status (Delivered, Retrying, Pending, Failed), HTTP status, duration, attempts, next retry, the first 2 KB of the response and the payload, with Redeliver (the same payload as a new delivery). The switch on each row pauses / resumes it.
Events
| Event | When | data |
|---|---|---|
task.created | a task is created (app or API) | Task |
task.updated | title, description, dates, labels, assignees, members, checklist items ticked / unticked | Task (+ changes.fields) |
task.moved | to another list (changes.list: {from, to} — list names), or to another board (changes.board, changes.key.from) | Task |
task.archived / task.restored | archived / restored; task.restored also when a deletion is undone | Task |
task.deleted | deleted (moved to the trash, deleted: true) | Task |
task_link.added / task_link.removed | a link between two tasks is added / removed (Linked tasks) | { id, type, source, target, created_at, created_by } — type relates, blocks or duplicates, from source to target (each { id, key, title, board_id }) |
comment.created / comment.updated / comment.deleted | comments and replies; a restored comment is comment.updated | Comment (+ task) |
board.created / board.updated / board.archived / board.restored | boards; updated covers name, access, public link, members and lists (changes.fields) | Board |
member.added / member.removed | someone joins the workspace (added on the Members page or over the API, accepts an invitation, added from the Share dialog) or leaves / is removed | Member |
time_entry.created / time_entry.updated / time_entry.deleted | an entry is logged, or a timer stops; changed; deleted (running timers aren't announced) | TimeEntry (+ task) |
ping | "Send test" / POST …/ping only | { webhook: { id, url, description, events } } |
Not (yet) announced: membership changes made by OIDC group sync, automatic instance-admin memberships and Admin → Users; workspace settings; attachments; notifications.
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" } }
}
idis the delivery id (alsoX-Sol2flow-Delivery) — use it to ignore duplicates: delivery is at least once.datahas the shape the REST API returns for the resource (see the OpenAPI document): Task =TaskSummarypluslistanddeleted; Comment withoutrepliesplustask: { id, key, title, board_id }; Board without the per-userrole; TimeEntry plustask; Member ={ user: { id, name, username }, role }.- Privacy: no email addresses anywhere;
actorisnullfor system changes. - Payloads above 64 KB are trimmed: rich-text documents (
description,doc) are dropped (their plain text stays), long strings are cut, and"truncated": trueis added. - The payload is built when the change happens (inside its transaction), so it shows the state right after the change, even if the delivery is retried hours later.
Delivery
Each delivery is a POST with Content-Type: application/json, User-Agent: sol2flow-webhooks/1.0 and:
| Header | |
|---|---|
X-Sol2flow-Event | the event name |
X-Sol2flow-Delivery | the delivery id (= payload id) |
X-Sol2flow-Webhook | the webhook id |
X-Sol2flow-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)> |
- Success: any 2xx within 10 seconds. Redirects are not followed (a 3xx is a failure). Only the first 2 KB of the response are read and kept.
- Retries after a network error, timeout, 5xx, 408 or 429: after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h (7
attempts in about 21 hours), then the delivery is
failed. Other 4xx (and 3xx) are final at once. - Automatic switch-off: after 20 failed deliveries in a row (each after all its retries, or final at once) the webhook is turned off and the workspace's admins get an in-app notification. Turning it back on resets the counter and sends the deliveries that were waiting. Test pings don't count.
- Paused webhooks record no new events; deliveries already queued wait and are sent on resume.
- Retention: deliveries older than 30 days are deleted by the daily cleanup job.
Ordering and duplicates
Events are recorded together with the change that caused them: a change that fails sends nothing. Deliveries can arrive
more than once and, after retries, out of order — use the payload's id to ignore duplicates and created_at to
order them.
Verifying the signature
Compute the HMAC over the raw request body (before any JSON parsing), compare in constant time, and reject old timestamps (replays).
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
The key is the whole secret string including whsec_, as UTF-8 bytes.
Allowed target addresses
A webhook URL makes the server send requests, so it could be aimed at internal services. The rules:
- Only
http:andhttps:; nouser:password@; at most 2000 characters. - The host is resolved and every address must be allowed; literal IPs are checked the same way.
- Refused unless the instance allows private targets: loopback
127.0.0.0/8,::1; private10/8,172.16/12,192.168/16, unique-localfc00::/7; link-local169.254/16,fe80::/10; CGNAT100.64/10; documentation and benchmark ranges. IPv6 addresses that embed an IPv4 one (IPv4-mapped::ffff:…, NAT6464:ff9b::/96, 6to42002::/16) are judged by that IPv4 address. - Always refused:
0.0.0.0/8,::, multicast, broadcast,240/4, and the cloud metadata endpoints169.254.169.254,169.254.170.2,100.100.100.200,fd00:ec2::254— also with private targets allowed. - Checked when saving (422
webhook_url_blocked,webhook_url_unresolved,webhook_url_invalid) and again before every attempt (DNS rebinding): the request is then sent to exactly the address that was checked (the resolved IP is pinned via the socket'slookup, with the original host name forHostand TLS/SNI). A blocked address fails the delivery without retry; a failed DNS lookup is retried.