Webhooks
Receive real-time HTTP notifications when events occur in your Decuga project
Overview
Webhooks let you subscribe to events in a Decuga project and receive an HTTP POST request to your endpoint every time one occurs. Common use cases include:
- Posting task updates to a Slack channel
- Syncing Decuga tasks with an external system (Jira, Linear, etc.)
- Triggering CI/CD pipelines when a sprint is completed
- Sending custom notifications to a mobile app
- Auditing all project changes into an external log store
Webhooks are configured per project under Project → Webhooks. Each webhook specifies a target URL, a signing secret, and a list of event types to subscribe to. Deliveries are asynchronous — they do not block the action that triggered them.
Payload format
Every delivery is a JSON POST to your endpoint with the following envelope:
{
"id": "9f4a2b1c-...", // unique delivery ID (UUID)
"webhookId": "3e8d1f7a-...", // the webhook that sent this
"event": "TASK_STATUS_CHANGED",
"projectId": "a1b2c3d4-...",
"timestamp": "2026-04-29T14:32:00.123456Z",
"data": { ... } // entity-specific payload (see Event reference)
}Each request includes the following HTTP headers:
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Decuga-Event | The event type, e.g. TASK_CREATED |
| X-Decuga-Delivery | Unique UUID for this delivery attempt |
| X-Decuga-Signature | sha256=<hmac-hex> — HMAC-SHA256 of the raw body |
Signature verification
Every delivery is signed using HMAC-SHA256. The signature is computed over the raw JSON request body using the signing secret you set when creating the webhook. Always verify the signature before processing the payload.
signature = HMAC-SHA256(key=secret, message=raw_body)
header = "sha256=" + hex(signature)const crypto = require('crypto')
function verifySignature(rawBody, secret, signatureHeader) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex')
// Use timingSafeEqual to prevent timing attacks
const a = Buffer.from(signatureHeader, 'utf8')
const b = Buffer.from(expected, 'utf8')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
// Express example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-decuga-signature']
if (!verifySignature(req.body, process.env.WEBHOOK_SECRET, sig)) {
return res.status(401).send('Invalid signature')
}
const event = JSON.parse(req.body)
console.log('Received:', event.event, event.data)
res.sendStatus(200)
})import hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
@app.route('/webhook', methods=['POST'])
def webhook():
raw_body = request.get_data()
signature = request.headers.get('X-Decuga-Signature', '')
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401, 'Invalid signature')
event = request.get_json()
print(f"Received: {event['event']}", event['data'])
return '', 200import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
secret := []byte(os.Getenv("WEBHOOK_SECRET"))
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(r.Header.Get("X-Decuga-Signature")), []byte(expected)) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
fmt.Printf("Event received: %s\n", r.Header.Get("X-Decuga-Event"))
w.WriteHeader(http.StatusOK)
}Event reference
The following event types can be subscribed to when configuring a webhook.
Task events
| Event | Description |
|---|---|
| TASK_CREATED | A new task was created in the project. |
| TASK_UPDATED | Any field on a task was updated (title, description, priority, etc.). |
| TASK_DELETED | A task was permanently deleted. |
| TASK_STATUS_CHANGED | The status of a task changed (e.g. TODO → IN_PROGRESS). Also fires TASK_UPDATED. |
| TASK_ASSIGNED | A task was assigned to a team member. |
Sprint events
| Event | Description |
|---|---|
| SPRINT_CREATED | A sprint was created in planning state. |
| SPRINT_STARTED | A sprint was moved from PLANNING to ACTIVE. |
| SPRINT_COMPLETED | A sprint was completed. Includes final velocity in the data payload. |
| SPRINT_DELETED | A sprint in PLANNING state was deleted. |
Member events
| Event | Description |
|---|---|
| MEMBER_INVITED | A new member was invited to the project. |
| MEMBER_REMOVED | A member was removed from the project. |
| MEMBER_ROLE_UPDATED | A member's role or permissions were updated. |
Comment events
| Event | Description |
|---|---|
| COMMENT_CREATED | A comment was added to a task or document. |
| COMMENT_RESOLVED | A comment was marked as resolved. |
| COMMENT_DELETED | A comment was deleted. |
Data models
The data field in the payload envelope contains the full entity object at the time of the event. Below are the shapes for each entity type.
Task
Used by: TASK_CREATED, TASK_UPDATED, TASK_STATUS_CHANGED, TASK_ASSIGNED
{
"id": "uuid",
"projectId": "uuid",
"sprintId": "uuid | null",
"assigneeUserId": "uuid | null",
"assigneeName": "string | null",
"reporterUserId": "uuid",
"reporterName": "string | null",
"title": "string",
"description": "string | null", // HTML content
"status": "TODO | IN_PROGRESS | IN_REVIEW | DONE",
"priority": "LOW | MEDIUM | HIGH | CRITICAL",
"storyPoints": "integer | null",
"dueDate": "YYYY-MM-DD | null",
"linkedDocumentIds": ["uuid"],
"tags": ["string"],
"customFields": { "fieldDefId": "value" },
"position": 0,
"createdAt": "ISO-8601",
"updatedAt": "ISO-8601"
}TASK_DELETED and SPRINT_DELETED carry a minimal object (the full entity is already gone):
{
"taskId": "uuid",
"projectId": "uuid"
}Sprint
Used by: SPRINT_CREATED, SPRINT_STARTED, SPRINT_COMPLETED
{
"id": "uuid",
"projectId": "uuid",
"name": "string",
"goal": "string | null",
"status": "PLANNING | ACTIVE | COMPLETED",
"startDate": "YYYY-MM-DD | null",
"endDate": "YYYY-MM-DD | null",
"velocity": "integer | null", // populated after SPRINT_COMPLETED
"createdAt": "ISO-8601"
}Member
Used by: MEMBER_INVITED, MEMBER_ROLE_UPDATED
{
"id": "uuid",
"projectId": "uuid",
"userId": "uuid | null", // null until invite is accepted
"userName": "string | null",
"userEmail": "string | null",
"role": "OWNER | EDITOR | VIEWER",
"roleName": "string | null", // custom role name if set
"status": "PENDING | ACTIVE",
"inviteEmail": "string | null",
"createdAt": "ISO-8601"
}Comment
Used by: COMMENT_CREATED, COMMENT_RESOLVED
{
"id": "uuid",
"targetType": "TASK | DOCUMENT",
"targetId": "uuid",
"authorUserId": "uuid",
"authorName": "string",
"body": "string", // HTML content
"resolved": false,
"resolvedByUserId": "uuid | null",
"resolvedAt": "ISO-8601 | null",
"createdAt": "ISO-8601"
}Quickstart examples
Post task updates to Slack
const express = require('express')
const crypto = require('crypto')
const axios = require('axios')
const app = express()
const SECRET = process.env.WEBHOOK_SECRET
const SLACK = process.env.SLACK_WEBHOOK_URL
app.post('/decuga-hook', express.raw({ type: '*/*' }), async (req, res) => {
// 1 — verify signature
const sig = req.headers['x-decuga-signature'] ?? ''
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
return res.status(401).send('bad signature')
// 2 — parse and handle
const { event, data } = JSON.parse(req.body)
if (event === 'TASK_STATUS_CHANGED') {
await axios.post(SLACK, {
text: `*${data.title}* moved to *${data.status.replace('_', ' ')}`
+ (data.assigneeName ? ` (assignee: ${data.assigneeName})` : ''),
})
}
res.sendStatus(200)
})
app.listen(3000)Mirror sprint completion to a Google Sheet
from flask import Flask, request, abort
import hmac, hashlib, os, gspread
app = Flask(__name__)
SECRET = os.environ['WEBHOOK_SECRET']
gc = gspread.service_account()
sheet = gc.open('Sprint Tracker').sheet1
@app.route('/hook', methods=['POST'])
def hook():
body = request.get_data()
sig = request.headers.get('X-Decuga-Signature', '')
exp = 'sha256=' + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, exp):
abort(401)
ev = request.get_json()
if ev['event'] == 'SPRINT_COMPLETED':
d = ev['data']
sheet.append_row([d['name'], d['status'], d.get('velocity', 0), d['endDate']])
return '', 200Best practices
- Always verify the signature. Never process a payload without first verifying the X-Decuga-Signature header. Use a constant-time comparison function to prevent timing attacks.
- Respond quickly (within 10 s). Decuga waits up to 10 seconds for a response. Offload heavy processing to a background queue and return 200 immediately.
- Make your handler idempotent. Use the delivery id field to deduplicate in case you receive the same event more than once (network retries, infrastructure restarts, etc.).
- Protect your endpoint. Expose the endpoint over HTTPS only. Consider IP allowlisting or an additional API key as a defence-in-depth measure on top of signature verification.
- Subscribe only to what you need. Subscribe to specific event types rather than all events. This reduces noise and makes your handler simpler to reason about.
- Use the Test button during development. The webhook settings page has a "Test" button that sends a PING payload so you can confirm delivery and inspect headers without triggering a real event.
- Monitor the delivery log. The Logs panel in webhook settings shows the last 50 deliveries with HTTP status codes and error messages. Check it when troubleshooting missed events.