# Guide to Teamwork.com Webhooks: Features and Best Practices

Teamwork.com webhooks tell your other systems what is happening in your projects: a task is created or completed, a milestone moves, time is logged, a file is downloaded, an invoice is completed. If your team runs client work in Teamwork.com, webhooks are how that activity reaches your billing, reporting and notification tools without polling the API.

This guide covers Teamwork.com Projects webhooks: how to set up a signed Version 2 webhook, how the signature works, the events and their payloads, Teamwork's short retry window, and the practices that keep a receiver reliable. Teamwork Desk and Teamwork Spaces have webhooks of their own, documented separately.

## What are Teamwork.com webhooks?

A Teamwork.com webhook links one event to one endpoint URL. When the event happens, Teamwork sends an HTTP `POST` to the URL. Webhooks are available on paid plans, and can be set up for the whole site or for a single project.

Each webhook has a version. Version 2, the current one, sends more data and lets you pick JSON, XML or Form as the content type. It also has a **Token** field. When you set a token, Teamwork calculates an HMAC-SHA256 of the request body with the token as the key and sends it, hex-encoded, in the `X-Projects-Signature` header. Without a token, Teamwork doesn't describe the request as signed, so set one.

The signature covers only the body. There is no timestamp, so a captured request verifies whenever it is sent again.

## Teamwork.com webhook features

| Feature | Details |
| --- | --- |
| Availability | Paid Teamwork.com plans |
| Configuration | Site settings (**Webhooks**, then **Registered Events**), a project's settings, or the API |
| Permissions | Owner company site administrators for site level, project administrators and above for project level |
| Subscription | One event per webhook |
| Versions | Version 2 (current) and Version 1 (legacy) |
| Content types | JSON, XML or Form on Version 2 |
| Signature header | `X-Projects-Signature`, when a token is set |
| Scheme | HMAC-SHA256, lowercase hex, no prefix |
| Signed content | The raw request body, nothing else |
| Secret | The token you set on the webhook, used as a UTF-8 string |
| Other headers | `X-Projects-Event` and `X-Projects-Delivery`, shown in Teamwork's header screenshot |
| Success | A `200` response |
| Retries | Once an hour, at most 3 attempts, then the event is discarded |
| Auto-deactivate | After three or more discarded events |
| Suppressing | Add `fireWebhook=false` to an API call to stop it firing webhooks |

Teamwork publishes no source IP list, and its pages don't give an exact timeout beyond "a reasonable time frame".

## Common events

Teamwork lists 125 events, written as an entity and an option. The event names in headers use the form `ENTITY.OPTION`, for example `TASK.CREATED`.

| Entity | Options |
| --- | --- |
| `TASK` | `COMPLETED`, `CREATED`, `DELETED`, `MOVED`, `REMINDER`, `REOPENED`, `TAGGED`, `UNTAGGED`, `UPDATED` |
| `TASKLIST` | `COMPLETED`, `CREATED`, `CREATEDFROMTEMPLATE`, `DELETED`, `REOPENED`, `UPDATED` |
| `PROJECT` | `ARCHIVED`, `COMPLETED`, `COPIED`, `CREATED`, `DELETED`, `REOPENED`, `TAGGED`, `UNTAGGED`, `UPDATED` |
| `MILESTONE` | `COMPLETED`, `CREATED`, `DELETED`, `REMINDER`, `REOPENED`, `TAGGED`, `UNTAGGED`, `UPDATED` |
| `TIME` | `CREATED`, `DELETED`, `TAGGED`, `UNTAGGED`, `UPDATED` |
| `COMMENT`, `MESSAGEREPLY` | `CREATED`, `DELETED`, `UPDATED` |
| `MESSAGE`, `NOTEBOOK`, `LINK` | `CREATED`, `DELETED`, `TAGGED`, `UNTAGGED`, `UPDATED` |
| `FILE` | `CREATED`, `DELETED`, `DOWNLOADED`, `TAGGED`, `UNTAGGED`, `UPDATED` |
| `INVOICE` | `COMPLETED`, `CREATED`, `DELETED`, `REOPENED`, `UPDATED` |
| `FORM` | `CREATED`, `DELETED`, `PUBLISHED`, `SUBMITTED` |
| `BUDGET`, `EXPENSE`, `RISK`, `ROLE`, `TEAM`, `PROJECTUPDATE` | `CREATED`, `DELETED`, `UPDATED` |
| Site level only | `COMPANY`, `USER`, `STATUS`, `PORTFOLIOBOARD`, `PORTFOLIOCOLUMN` (`CREATED`, `DELETED`, `UPDATED`), `PORTFOLIOCARD` (adds `MOVED`, `REOPENED`), `CALENDEREVENT` (adds `REMINDER`) |
| Other | `PROJECTRATE.UPDATED`, `TIMER.CREATED`, `GOOGLEDRIVE.CONNECTED`, `GOOGLEDRIVE.INTEGRATED` |
| Being replaced | `CARD` and `COLUMN` (`CREATED`, `DELETED`, `UPDATED`), to be deprecated in favour of upcoming `stage` events |

The body doesn't name the event. Teamwork's documentation shows the event in an `X-Projects-Event` header, alongside a delivery UUID in `X-Projects-Delivery`, but only in a screenshot of one delivery's headers (`TIME.CREATED`). Its sample headings are lowercase (`time.created`), so compare event names case-insensitively.

A JSON body holds `eventCreator`, the user who triggered the event, and one key per affected entity. Here is Teamwork's example for `time.created` and `time.updated`, trimmed:

```json
{
  "eventCreator": {
    "id": 238860,
    "firstName": "Marc",
    "lastName": "Cashman",
    "avatar": "https://s3.amazonaws.com/TWFiles/343429/userAvatar/..."
  },
  "time": {
    "id": 23200713,
    "hours": 2,
    "minutes": 0,
    "date": "2024-10-03T08:38:00Z",
    "dateUserPerspective": "2024-10-03T09:38:00+01:00",
    "userId": 238860,
    "description": "Review draft of 2nd chapter",
    "billable": true,
    "taskId": 41276637,
    "projectId": 732893,
    "tags": [{ "id": 66934, "name": "Report", "color": "2f8de4" }],
    "dateCreated": "2024-10-03T10:39:50Z",
    "dateUpdated": "2024-10-03T10:39:50Z"
  }
}

```

Some bodies carry more than one entity: Teamwork's tasklist example includes the tasklist's `milestone` as well as the `taskList`. Every `DELETED` event uses the same small shape:

```json
{
  "id": 1,
  "projectId": 1,
  "eventCreator": { "id": 1, "firstName": "Holly", "lastName": "Bracken", "avatar": "https://..." }
}

```

## Setting up Teamwork.com webhooks

1. As an owner company site administrator, open **Settings** from your profile menu, go to the **Webhooks** subsection, and turn webhooks on.
2. Under **Registered Events**, click **Add Webhook**. For a single project, open the project's settings and click **Add Project Webhook** in its **Registered Events** subsection instead.
3. Choose the event, enter your endpoint URL, choose **Version 2**, choose **JSON** as the content type, and enter a long random **Token**. Store the same token server-side, for example as `TEAMWORK_WEBHOOK_TOKEN`.
4. Save, and repeat for each event you need. Several webhooks can point at the same URL.

If a new site-level webhook matches an existing project webhook's event and URL, Teamwork offers to promote the project webhook to site level. You can pause or delete a webhook from **Registered Events**, and you can create webhooks through the API as well.

If you receive Teamwork webhooks through Hookdeck, use the Hookdeck source URL as the endpoint and paste the token into the Teamwork.com source. Only Teamwork.com Projects webhooks are supported: use Version 2 and set a token, because Teamwork signs with it. A webhook without a token sends no signature, and its deliveries fail verification. Use the same token on every webhook that points at one Hookdeck source.

## Securing Teamwork.com webhooks

Compute an HMAC-SHA256 of the raw request body with the token as a UTF-8 string, hex-encode it, and compare it with `X-Projects-Signature` in constant time. Teamwork's own sample is in Go: `hmac.New(sha256.New, []byte(token))` followed by `hex.EncodeToString`.

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

function verifyTeamworkSignature(rawBody, signatureHeader, token) {
  if (!signatureHeader || !token) return false;
  const expected = crypto.createHmac('sha256', token).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/teamwork', express.raw({ type: '*/*' }), (req, res) => {
  const token = process.env.TEAMWORK_WEBHOOK_TOKEN;
  if (!token) return res.sendStatus(500); // fail closed

  if (!verifyTeamworkSignature(req.body, req.get('x-projects-signature'), token)) {
    return res.sendStatus(401);
  }

  const eventName = (req.get('x-projects-event') || '').toUpperCase();
  const payload = JSON.parse(req.body.toString('utf8'));
  // Queue { eventName, payload } and respond with 200 straight away.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

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

# In FastAPI: raw_body = await request.body()
# verify_teamwork_signature(raw_body, request.headers.get("x-projects-signature"),
#                           os.environ["TEAMWORK_WEBHOOK_TOKEN"])

```

Mounting `express.raw({ type: '*/*' })` matters more than usual here, because a Version 2 webhook can send JSON, XML or form-encoded bodies. Verifying the raw bytes works for all three.

To check your verifier, use the token `teamwork-guide-test-token` and this exact body:

```
{"id":1,"projectId":1,"eventCreator":{"id":1}}

```

The `X-Projects-Signature` value should be:

```
e6e7ea517b709cb9c29bb8ec2a93d83526acd301e683c11b5842709142954d06

```

> **Make Teamwork.com webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Projects-Signature` header on every delivery, answers Teamwork with a `200` straight away, and queues each task, project and time event so a slow receiver doesn't get its webhooks deactivated.

## Teamwork.com webhook limitations and pain points

### Three tries, then the event is gone

**The Problem:** Events from a short outage never arrive, and after a few of them the webhook stops altogether.

**Why It Happens:** Teamwork retries a failed delivery once an hour, at most 3 times, then discards it. Once three or more events have been discarded, the webhook is deactivated. Anything other than a `200`, or a request that times out, counts as a failure.

**Workarounds:**

* Return exactly `200`, not `201` or `204`, as soon as the request is verified and queued.
* Check **Registered Events** after an outage and reactivate any webhook Teamwork switched off.
* Reconcile through the API with `updatedAfter`-style filters after an outage of more than a couple of hours.

**How Hookdeck Can Help:** Hookdeck answers Teamwork with a `200` as soon as each delivery arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure. Your downtime no longer counts toward Teamwork's three attempts or its deactivation rule.

### No token, no signature

**The Problem:** Requests arrive without `X-Projects-Signature`, and verification fails.

**Why It Happens:** The token is optional, and the checksum is documented only for Version 2 webhooks. A Version 1 webhook, or a Version 2 webhook with an empty token, isn't described as signed.

**Workarounds:**

* Edit each webhook to Version 2 and set a token.
* Reject requests with no signature header, rather than treating them as trusted.

**How Hookdeck Can Help:** Hookdeck marks unsigned requests as failed verification and keeps them, so a webhook created without a token shows up straight away in the request log.

### One event per webhook

**The Problem:** A full integration needs dozens of webhooks, and one is easy to miss.

**Why It Happens:** Each webhook carries a single event, and there are 125 to choose from. Some entities are site level only.

**Workarounds:**

* Create webhooks through the API from a list in source control, so the set is reproducible.
* Point them all at one URL and route on `X-Projects-Event`.

**How Hookdeck Can Help:** Hookdeck receives every webhook on one source and can [filter and route](/docs/filters) by header, so each event type reaches the right service without one endpoint per event.

### The event name is only in a header

**The Problem:** Without the header, you have to guess the event from the body.

**Why It Happens:** Bodies carry the entity, not the event. Every `DELETED` event has the same `{id, projectId, eventCreator}` shape, so a deletion body doesn't even say what was deleted.

**Workarounds:**

* Route on `X-Projects-Event`, upper-cased, and log it with each event.
* If you use a separate URL per event, include the event name in the path as a second signal.

**How Hookdeck Can Help:** Hookdeck keeps the headers and body of every request together, so the event name is always available when you inspect or replay a delivery.

### No replay protection

**The Problem:** A captured request still verifies if it is sent again later.

**Why It Happens:** Only the body is signed. There's no timestamp to check.

**Workarounds:**

* Deduplicate on `X-Projects-Delivery`, falling back to the event name plus the entity's `id` and `dateUpdated`.
* Keep the endpoint on HTTPS.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries based on the header or body fields you choose.

## 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 and `await request.body()` in FastAPI, and parse only after the check passes.

### Choose JSON and Version 2

Version 2 is the version that supports the token checksum, and JSON is the format Teamwork's samples use. Mixing formats across webhooks makes every handler parse three body types.

### Queue, then respond with `200`

Teamwork's own advice is to save the parameters in a local queue, respond immediately with a `200`, and do any long work afterwards. Retries "could lead to duplicate actions on your side", so make processing idempotent. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Suppress webhooks from your own writes

When your integration writes back to Teamwork, for example adding a job number to a new task, add `fireWebhook=false` to the API call. That stops your own update from triggering another `TASK.UPDATED` and starting a loop.

### Plan for the stage events

`CARD` and `COLUMN` events are marked for deprecation, and `stage` events are coming in their place. Log unknown event names and acknowledge them with a `200`.

## Conclusion

Teamwork.com signs Version 2 webhooks that have a token with `X-Projects-Signature`, a hex HMAC-SHA256 of the raw body keyed with that token. Set a token on every webhook, verify the raw bytes, route on `X-Projects-Event`, and answer with a `200` straight away, because three hourly attempts are all Teamwork makes before it discards an event, and three discarded events switch the webhook off.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Teamwork signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Teamwork.com webhooks reliably in minutes.