Webhooks

Receive real-time HTTP notifications when events occur in your Decuga project

Real-time delivery
HMAC-SHA256 signed
Delivery logs

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.

Webhooks require the Manage Project permission. Any project member with that permission can create, edit, or delete webhooks.

Payload format

Every delivery is a JSON POST to your endpoint with the following envelope:

JSON — 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:

HeaderValue
Content-Typeapplication/json
X-Decuga-EventThe event type, e.g. TASK_CREATED
X-Decuga-DeliveryUnique UUID for this delivery attempt
X-Decuga-Signaturesha256=<hmac-hex> — HMAC-SHA256 of the raw body
Your endpoint must return a 2xx HTTP status within 10 seconds. Non-2xx responses or timeouts are recorded as failed deliveries in the delivery log. Decuga does not retry failed deliveries automatically.

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.

Algorithm
signature = HMAC-SHA256(key=secret, message=raw_body)
header    = "sha256=" + hex(signature)
Node.js
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)
})
Python (Flask)
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 '', 200
Go
import (
    "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

EventDescription
TASK_CREATEDA new task was created in the project.
TASK_UPDATEDAny field on a task was updated (title, description, priority, etc.).
TASK_DELETEDA task was permanently deleted.
TASK_STATUS_CHANGEDThe status of a task changed (e.g. TODO → IN_PROGRESS). Also fires TASK_UPDATED.
TASK_ASSIGNEDA task was assigned to a team member.

Sprint events

EventDescription
SPRINT_CREATEDA sprint was created in planning state.
SPRINT_STARTEDA sprint was moved from PLANNING to ACTIVE.
SPRINT_COMPLETEDA sprint was completed. Includes final velocity in the data payload.
SPRINT_DELETEDA sprint in PLANNING state was deleted.

Member events

EventDescription
MEMBER_INVITEDA new member was invited to the project.
MEMBER_REMOVEDA member was removed from the project.
MEMBER_ROLE_UPDATEDA member's role or permissions were updated.

Comment events

EventDescription
COMMENT_CREATEDA comment was added to a task or document.
COMMENT_RESOLVEDA comment was marked as resolved.
COMMENT_DELETEDA 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

JSON
{
  "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):

JSON
{
  "taskId":    "uuid",
  "projectId": "uuid"
}

Sprint

Used by: SPRINT_CREATED, SPRINT_STARTED, SPRINT_COMPLETED

JSON
{
  "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

JSON
{
  "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

JSON
{
  "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

Node.js
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

Python
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 '', 200

Best 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.
Decuga Webhooks API — v1Back to Home