Syncupp POSTs a JSON body to your endpoint when something happens in your workspace. Every delivery is signed, so you can be sure it came from us and was not modified in transit.
No third-party automation platform is involved — your server talks to ours directly.
Your URL must be https and publicly reachable. Private and loopback addresses are rejected — if you are developing locally, use a tunnel such as ngrok.
A POST with a JSON body and these headers:
POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Syncupp-Webhooks/1.0
X-Syncupp-Event: task.completed
X-Syncupp-Signature: t=1756108800,v1=5f2b9c...
X-Syncupp-Delivery: 3fa85f64-5717-4562-b3fc-2c963f66afa6
{
"event": "task.completed",
"delivered_at": "2026-08-25T09:20:00.000Z",
"workspace_id": "6a58b043b0e0c8212a9ac324",
"data": { }
}The header looks like t=1756108800,v1=5f2b9c.... t is the Unix timestamp we signed at; v1 is an HMAC-SHA256, hex encoded.
To check it:
{t}.{body} — the timestamp, a full stop, then the body.Node.js / Express
const crypto = require('crypto');
// The raw body is required — express.json() alone will not do.
app.post('/syncupp-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-Syncupp-Signature') || '';
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('='))
);
const expected = crypto
.createHmac('sha256', process.env.SYNCUPP_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body.toString('utf8')}`)
.digest('hex');
const ok =
parts.v1 &&
expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!ok) return res.status(400).send('bad signature');
// Reject anything older than five minutes.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300)
return res.status(400).send('stale');
const event = JSON.parse(req.body.toString('utf8'));
// ... do your work, then answer quickly (see below)
res.sendStatus(200);
}
);Python / Flask
import hmac, hashlib, time
from flask import request
@app.post("/syncupp-webhook")
def syncupp_webhook():
header = request.headers.get("X-Syncupp-Signature", "")
parts = dict(kv.split("=", 1) for kv in header.split(","))
body = request.get_data(as_text=True) # raw, not request.json
expected = hmac.new(
SYNCUPP_WEBHOOK_SECRET.encode(),
f"{parts['t']}.{body}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
return "bad signature", 400
if abs(time.time() - int(parts["t"])) > 300:
return "stale", 400
event = request.get_json()
# ... do your work
return "", 200If you skip this check, anyone who learns your URL can post events that look like ours. The secret is the only thing that distinguishes them.
| Event | Sent when |
|---|---|
task.created | A task was created. |
task.updated | A task was edited — title, description, dates, assignees, priority. |
task.completed | A task moved into a completed column. Fires on the move itself, not on every later change. |
task.deleted | A task was deleted. |
board.created | A board was created. |
board.updated | A board was edited. |
meeting.created | A meeting was created. |
meeting.updated | A meeting was edited. |
A webhook receives only the events you ticked. One endpoint can take several, and one workspace can have up to 20 endpoints.
Something unclear or missing? Write to webmaster@syncupp.com — including what you were trying to build helps more than a bug report.