# 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:

```json
{
  "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](/docs/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.

```javascript
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:

```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](/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](/docs/retries). 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](/docs/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](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Taiga webhooks reliably in minutes.