Skip to main content

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​

  1. Settings → Workspace → Webhooks → Add webhook: the payload URL, an optional description, and the events (checklist grouped by tasks, comments, boards, members, time entries).
  2. 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.
  3. Send test queues a ping event (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​

EventWhendata
task.createda task is created (app or API)Task
task.updatedtitle, description, dates, labels, assignees, members, checklist items ticked / untickedTask (+ changes.fields)
task.movedto another list (changes.list: {from, to} — list names), or to another board (changes.board, changes.key.from)Task
task.archived / task.restoredarchived / restored; task.restored also when a deletion is undoneTask
task.deleteddeleted (moved to the trash, deleted: true)Task
task_link.added / task_link.removeda 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.deletedcomments and replies; a restored comment is comment.updatedComment (+ task)
board.created / board.updated / board.archived / board.restoredboards; updated covers name, access, public link, members and lists (changes.fields)Board
member.added / member.removedsomeone joins the workspace (added on the Members page or over the API, accepts an invitation, added from the Share dialog) or leaves / is removedMember
time_entry.created / time_entry.updated / time_entry.deletedan 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" } }
}
  • id is the delivery id (also X-Sol2flow-Delivery) — use it to ignore duplicates: delivery is at least once.
  • data has the shape the REST API returns for the resource (see the OpenAPI document): Task = TaskSummary plus list and deleted; Comment without replies plus task: { id, key, title, board_id }; Board without the per-user role; TimeEntry plus task; Member = { user: { id, name, username }, role }.
  • Privacy: no email addresses anywhere; actor is null for 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": true is 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-Eventthe event name
X-Sol2flow-Deliverythe delivery id (= payload id)
X-Sol2flow-Webhookthe webhook id
X-Sol2flow-Signaturet=<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: and https:; no user: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; private 10/8, 172.16/12, 192.168/16, unique-local fc00::/7; link-local 169.254/16, fe80::/10; CGNAT 100.64/10; documentation and benchmark ranges. IPv6 addresses that embed an IPv4 one (IPv4-mapped ::ffff:…, NAT64 64:ff9b::/96, 6to4 2002::/16) are judged by that IPv4 address.
  • Always refused: 0.0.0.0/8, ::, multicast, broadcast, 240/4, and the cloud metadata endpoints 169.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's lookup, with the original host name for Host and TLS/SNI). A blocked address fails the delivery without retry; a failed DNS lookup is retried.