Guide to Taiga Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Configuration | Per project, in the project's admin settings |
| Subscription model | Every change in the project. No filter |
| Method and format | POST, application/json |
| Delivery timing | Synchronous or asynchronous, depending on the instance. tree.taiga.io is asynchronous |
| Signature header | X-TAIGA-WEBHOOK-SIGNATURE, plus X-Hub-Signature: sha1=<hex> |
| Scheme | HMAC-SHA1, hex digest, no prefix |
| Signed content | The raw request body, nothing else |
| Secret | The secret key on the webhook, used as UTF-8 |
| Replay window | None. No timestamp is sent or signed |
| Retries | Not documented. taiga-back sends each request once |
| Tools | Test 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:
| Field | Contents |
|---|---|
type | The object: milestone, userstory, task, issue, wikipage or test |
action | The change: create, delete, change or test |
by | The user who made the change |
date | When the notification was generated |
data | The current state of the object |
change | Only 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
- If you self-host, set
WEBHOOKS_ENABLED = Trueintaiga-back/settings/local.pyand restart taiga-back. Hosted Taiga needs no setting. - Generate a long random secret key and store it server-side, for example as
TAIGA_WEBHOOK_SECRET. - Open the project's admin settings and find the webhooks section, under integrations in current versions.
- Add a webhook with a name, your endpoint URL and the secret key.
- Use the webhook's test action to send a
testpayload, 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 + "." + actionand route on that. - Treat
test.testas a no-op that returns200.
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_datewith 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 = Trueand 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.