Agent skill

Teamwork Webhooks Skill

Receive and verify Teamwork.com Projects webhooks. Use when setting up Teamwork webhook handlers, debugging X-Projects-Signature verification, or handling events like TASK.CREATED, TASK.COMPLETED, PROJECT.CREATED, or COMMENT.CREATED from Teamwork Projects.

Install this skill

npx skills add hookdeck/webhook-skills --skill teamwork-webhooks


Covers Teamwork.com Projects webhooks, Version 2 only. Teamwork Desk and Teamwork Spaces webhooks are documented separately and are not covered here.

When to Use This Skill

  • How do I receive Teamwork Projects webhooks?
  • How do I verify the Teamwork X-Projects-Signature header?
  • How do I handle TASK.CREATED, TASK.COMPLETED, or PROJECT.CREATED events?
  • Why is my Teamwork webhook signature verification failing?
  • Why does my Teamwork webhook have no signature header?

Verification (core)

When a webhook has a token, Teamwork computes an HMAC-SHA256 of the raw POST body keyed with that token (as UTF-8 bytes) and sends it hex-encoded in the X-Projects-Signature header (no prefix, no timestamp). The token is optional in Teamwork, so you must choose Version 2 and set one, or no signature is sent.

Node (check the header is exactly 64 hex chars before Buffer.from(x, 'hex'), because invalid hex silently truncates):

const crypto = require('crypto');

function verifyTeamworkWebhook(rawBody, signature, token) {
  if (!signature || !token || !/^[0-9a-f]{64}$/i.test(signature)) return false;
  const expected = crypto.createHmac('sha256', token).update(rawBody).digest();
  return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), expected);
}

Python (compare_digest on bytes, so a non-ASCII header gives 401, not 500):

import hmac, hashlib

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

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Teamwork has no official SDK helper for webhook verification, so all three examples verify manually. Express examples read the body with express.raw({ type: '*/*' }) so any content type verifies.

Common Event Types

The event name arrives in the X-Projects-Event header in uppercase ENTITY.ACTION form. The body has no event field, only an eventCreator object and the entity (for example task, project, comment).

EventTriggered When
TASK.CREATEDA task is created
TASK.UPDATEDA task is updated
TASK.COMPLETEDA task is completed
TASK.DELETEDA task is deleted
PROJECT.CREATEDA project is created
COMMENT.CREATEDA comment is added
MILESTONE.COMPLETEDA milestone is completed
TIME.CREATEDTime is logged

All delete events share one payload: { "id", "projectId", "eventCreator" }. See references/overview.md for the full entity/action list.

Important Headers and Fields

ItemDescription
X-Projects-Signature headerHex HMAC-SHA256 of the raw body, keyed with the webhook token
X-Projects-Event headerEvent name, e.g. TASK.CREATED
eventCreator (body)id, firstName, lastName, avatar of the user who triggered the event

Respond 200 within 5 seconds. Any other status (including 3xx) counts as a failure: Teamwork retries hourly, up to 3 attempts, and deactivates the webhook after three discarded events. Queue work and acknowledge immediately.

Environment Variables

TEAMWORK_WEBHOOK_TOKEN=your_webhook_token   # the token set on the Version 2 webhook

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 teamwork --path /webhooks/teamwork

Use the Hookdeck URL as the webhook URL in Teamwork.

Hookdeck setup

Only Teamwork.com Projects webhooks are supported. Use Version 2 webhooks and set a token; Teamwork signs with it. Enter the same token as the signing secret on the Hookdeck source. See references/setup.md.

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 6, 2026

View on GitHub →