Gareth Wilson Gareth Wilson

Guide to Taiga Webhooks: Features and Best Practices

Published


Taiga webhooks send project activity to your own services. When a user story moves into a sprint, a task is closed, an issue is opened, a sprint changes dates or a wiki page is edited, Taiga posts the change to your endpoint, so you can update chat channels, sync with another tracker, feed reporting or trigger CI without polling the Taiga API.

This guide covers how Taiga webhooks are configured and signed, the payload and how to tell events apart, what happens when a delivery fails, and the best practices for running a Taiga receiver in production.

What are Taiga webhooks?

Taiga's docs describe them in one line: they "notify changes on milestones, user stories, tasks, issues and wiki pages sending POST requests signed with a private key, to the configured urls". Each project can have any number of webhooks, and each one receives every change in the project. There is no event filter.

Every request is signed. The X-TAIGA-WEBHOOK-SIGNATURE header holds a hex HMAC-SHA1 of the raw body, keyed with the secret key you set on the webhook. Taiga's open-source backend also sends the same digest in X-Hub-Signature as sha1=<hex>, "for backward compatibility".

If you run Taiga yourself, webhooks are off until you enable them. Add WEBHOOKS_ENABLED = True to taiga-back/settings/local.py.

Taiga webhook features

FeatureDetails
ConfigurationPer project, in the project's admin settings
Subscription modelEvery change in the project. No filter
Method and formatPOST, application/json
Delivery timingSynchronous or asynchronous, depending on the instance. tree.taiga.io is asynchronous
Signature headerX-TAIGA-WEBHOOK-SIGNATURE, plus X-Hub-Signature: sha1=<hex>
SchemeHMAC-SHA1, hex digest, no prefix
Signed contentThe raw request body, nothing else
SecretThe secret key on the webhook, used as UTF-8
Replay windowNone. No timestamp is sent or signed
RetriesNot documented. taiga-back sends each request once
ToolsTest and resend actions, and a log of the last ten requests per webhook

Taiga publishes no source IP list. On a self-hosted instance, requests come from your own servers, and an operator can block webhooks to private and reserved addresses.

Common events

Every body has the same base structure:

FieldContents
typeThe object: milestone, userstory, task, issue, wikipage or test
actionThe change: create, delete, change or test
byThe user who made the change
dateWhen the notification was generated
dataThe current state of the object
changeOnly on change: the diff and any comment

Neither field identifies the event on its own, because every object type has create, change and delete. Combine them, for example userstory.change or issue.create. milestone is what the Taiga UI calls a sprint. The test action sends type and action both set to test.

Here is Taiga's userstory.change example, with most of data removed:

{
  "type": "userstory",
  "action": "change",
  "date": "2016-04-12T12:18:19.685Z",
  "data": {
    "id": 139,
    "ref": 72,
    "subject": "test user story 5",
    "permalink": "http://localhost:9001/project/project-0/us/72",
    "project": { "id": 1, "name": "Project Example 0" },
    "status": { "id": 1, "name": "New", "slug": "new", "is_closed": false },
    "milestone": { "id": 1, "name": "Sprint 2016-2-16" },
    "is_blocked": true,
    "modified_date": "2016-04-12T12:18:19+0000"
  },
  "change": {
    "diff": {
      "milestone": { "from": null, "to": "Sprint 2016-2-16" }
    },
    "comment": "",
    "comment_html": "",
    "delete_comment_date": null
  }
}

change.diff lists each changed field with from and to. A comment arrives as a change with comment and comment_html set. data.ref is the number users see in Taiga, such as #72, while data.id is the internal ID. Note that date and data.modified_date use different timestamp formats.

Taiga's backend also serialises epics and epic-related user stories, which the docs page does not list. Handle unknown type values with a 200.

Setting up Taiga webhooks

  1. If you self-host, set WEBHOOKS_ENABLED = True in taiga-back/settings/local.py and restart taiga-back. Hosted Taiga needs no setting.
  2. Generate a long random secret key and store it server-side, for example as TAIGA_WEBHOOK_SECRET.
  3. Open the project's admin settings and find the webhooks section, under integrations in current versions.
  4. Add a webhook with a name, your endpoint URL and the secret key.
  5. Use the webhook's test action to send a test payload, signed like any other request.

For local development, the Hookdeck CLI (hookdeck listen 3000 taiga --path /webhooks/taiga) gives you a public HTTPS URL that forwards to your local server, with no account required.

Securing Taiga webhooks

Read X-TAIGA-WEBHOOK-SIGNATURE, compute an HMAC-SHA1 of the raw request body with the secret key as a UTF-8 string, encode it as hex and compare in constant time. Taiga's own Python and PHP samples compare with ==, which leaks timing, so don't copy that part.

const crypto = require('crypto');
const express = require('express');

function verifyTaigaSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha1', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/taiga', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyTaigaSignature(req.body, req.get('x-taiga-webhook-signature'), process.env.TAIGA_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  const key = `${event.type}.${event.action}`; // e.g. userstory.change
  // Route on key, then process asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac
import os


def verify_taiga_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha1).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))


# In FastAPI: raw_body = await request.body()
# verify_taiga_signature(raw_body, request.headers.get("x-taiga-webhook-signature"),
#                        os.environ["TAIGA_WEBHOOK_SECRET"])

To check your verifier, use the secret key taiga-test-key and this exact body:

{"action": "test", "type": "test", "by": {"id": 6, "username": "user1"}, "date": "2016-04-12T12:00:56.335Z", "data": {"test": "test"}}

The signature should be:

b6c14fa12b0825a091edcc592b7c6c50b4f0c301

If you read X-Hub-Signature instead, strip the sha1= prefix first. The X-TAIGA-WEBHOOK-SIGNATURE header has no prefix.

Make Taiga webhooks production-ready. Hookdeck Event Gateway verifies X-TAIGA-WEBHOOK-SIGNATURE, stores every project change the moment Taiga sends it, and retries delivery to your service, which Taiga itself does not do.

Taiga webhook limitations and pain points

A failed request is not sent again

The Problem: Your endpoint was down for a few minutes, and the changes made during that time never reach it.

Why It Happens: Taiga's docs say nothing about retries. In taiga-back's code, each notification is sent once, and a failure is written to the webhook's log, which keeps only the last ten requests.

Workarounds:

  • Keep the receiver highly available, and answer quickly.
  • Check the webhook's log in Taiga after an outage, and use the resend action on failed requests while they are still among the last ten.
  • Reconcile against the Taiga API after any long outage.

How Hookdeck Can Help: Hookdeck accepts each request as soon as Taiga sends it and stores it, then retries delivery to your service on your own schedule. Your downtime no longer loses events, and anything that failed can be replayed.

Two fields make one event

The Problem: A handler written for userstory events also fires on deletions, or a router that keys on one field sends everything to the wrong place.

Why It Happens: type names the object and action names the change. Neither identifies the event alone, and no header carries either.

Workarounds:

  • Build the key type + "." + action and route on that.
  • Treat test.test as a no-op that returns 200.

How Hookdeck Can Help: Hookdeck filters can match on both type and action together, so each connection only receives the combinations it handles.

SHA-1 and a no-timestamp signature

The Problem: Security reviews flag SHA-1, and a captured request can be replayed.

Why It Happens: Taiga uses HMAC-SHA1, and the signature covers only the body. HMAC-SHA1 is still considered sound for message authentication, but there is no timestamp, so nothing stops a replay.

Workarounds:

  • Keep the endpoint on HTTPS so requests cannot be captured in transit.
  • Make handlers idempotent, and compare data.modified_date with the state you hold before applying a change.

How Hookdeck Can Help: Hookdeck's deduplication can drop repeated requests based on fields such as type, action, data.id and date.

Self-hosted instances send nothing by default

The Problem: You add a webhook on your own Taiga, and no requests arrive.

Why It Happens: taiga-back only sends webhooks when WEBHOOKS_ENABLED = True is set. A private-address block, if enabled, also stops requests to internal hosts.

Workarounds:

  • Set WEBHOOKS_ENABLED = True and restart taiga-back.
  • Use a public URL for the receiver, or allow the address if it is internal.

How Hookdeck Can Help: A Hookdeck source gives a self-hosted Taiga a public HTTPS URL to send to, which then forwards to services inside your network.

Best practices

Verify against the raw body

The signature covers the exact bytes. Use express.raw({ type: '*/*' }) in Express, await request.text() in a Next.js route handler, or await request.body() in FastAPI, and parse only after verifying.

Use one webhook per destination

Taiga lets you add as many webhooks as you like to a project. Give each consuming service its own webhook and secret key rather than chaining services behind one handler, so one slow consumer cannot cause another to miss events.

Read change.diff instead of diffing yourself

On change actions, change.diff tells you exactly which fields moved. Use it to decide whether the change matters, for example only acting when status or milestone changes.

Build your own idempotency key

There is no event ID. Combine type, action, data.id and date into a key and store it before processing. See our guide to webhook idempotency.

Acknowledge fast and process asynchronously

Taiga sends each request once, so the less your handler does before answering, the less can go wrong. Verify, store and return 200, then process from a queue. See why to process webhooks asynchronously.

Conclusion

Taiga signs every webhook with a hex HMAC-SHA1 of the raw body in X-TAIGA-WEBHOOK-SIGNATURE. Verify it in constant time, route on the pair of type and action, read change.diff for what changed, enable webhooks explicitly if you self-host, and plan for the fact that a failed request is not sent again.

Hookdeck Event Gateway checks every signature, stores each change as Taiga sends it and retries delivery until your service has processed it, adding the retries Taiga leaves out.

Get started with Hookdeck for free and handle Taiga webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.