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:
{
"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:
{
"id": 1,
"projectId": 1,
"eventCreator": { "id": 1, "firstName": "Holly", "lastName": "Bracken", "avatar": "https://..." }
}
Setting up Teamwork.com webhooks
- As an owner company site administrator, open Settings from your profile menu, go to the Webhooks subsection, and turn webhooks on.
- 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.
- 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. - 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.
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:
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 verifies the
X-Projects-Signatureheader on every delivery, answers Teamwork with a200straight 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, not201or204, 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 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 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'sidanddateUpdated. - Keep the endpoint on HTTPS.
How Hookdeck Can Help: Hookdeck's 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 and our guide to 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 sits in front of your receiver, checks every Teamwork signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Teamwork.com webhooks reliably in minutes.