# Guide to ClickUp Webhooks: Features and Best Practices

ClickUp webhooks tell your systems what is happening in a Workspace: a task is created, changes status, gets a new assignee or a comment, moves to another List, or a Space, Folder, Goal or Target changes. If you sync ClickUp with another tool, report on work in progress, or trigger automation from task activity, webhooks are how those changes reach you without polling the API.

This guide covers how ClickUp webhooks are registered and signed, the events and their payloads, the health rules that can switch a webhook off, and the best practices for running a ClickUp receiver in production.

## What are ClickUp webhooks?

A ClickUp webhook is a registration created through the API. It has an endpoint URL, the list of events it subscribes to, and optionally a location such as a Space or a List that narrows which events it receives. When a subscribed event happens, ClickUp sends a `POST` with a JSON body to the endpoint.

Webhooks belong to the user whose token created them. If that user is later disabled, the webhook remains but stops firing, because ClickUp checks before every delivery that the user is still part of the relevant part of the Workspace.

Every delivery is signed. Each registration has its own secret, returned when the webhook is created, and ClickUp sends a hex HMAC-SHA256 of the raw request body in the `X-Signature` header.

The detail that matters most in production is the health policy. ClickUp suspends a webhook immediately if your endpoint answers `401` or `410`, and it sends no notification when it does.

## ClickUp webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only: `POST /api/v2/team/{team_id}/webhook`. There is no UI for API webhooks |
| Ownership | Tied to the user whose token created the webhook |
| Subscription model | A list of events, or `*` for all, per registration |
| Location filter | At most one of `space_id`, `folder_id`, `list_id`, `task_id`. The most specific applies |
| Signature header | `X-Signature` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | The raw request body, nothing else |
| Secret | One per registration, returned as `webhook.secret` |
| Replay window | None. No timestamp is sent or signed |
| Response window | 7 seconds. Any non-`2xx` is a failure |
| Retries | Up to five per event, then the event is dropped and `fail_count` goes up |
| Suspension | At `fail_count` 100, or immediately on a `401` or `410` |
| Notifications | None when a webhook's health changes |
| Source IPs | No dedicated IP addresses |

ClickUp says it uses "our domain name and dynamic addressing" for webhooks, so there is nothing to allowlist and the signature is the credential.

## Common events

ClickUp documents 28 events. The `event` field in the body names the one that fired.

| Area | Events | Fires when |
| --- | --- | --- |
| Tasks | `taskCreated`, `taskUpdated`, `taskDeleted` | A task is created, updated or deleted |
| Task fields | `taskPriorityUpdated`, `taskStatusUpdated`, `taskAssigneeUpdated`, `taskDueDateUpdated`, `taskTagUpdated` | A task's priority, status, assignees, due date or tags change |
| Task activity | `taskMoved`, `taskCommentPosted`, `taskCommentUpdated` | A task moves to another List, or a comment is added or edited |
| Time | `taskTimeEstimateUpdated`, `taskTimeTrackedUpdated` | A time estimate is set or changed, or tracked time is added, updated or deleted |
| Lists | `listCreated`, `listUpdated`, `listDeleted` | A List is created, updated or deleted |
| Folders | `folderCreated`, `folderUpdated`, `folderDeleted` | A Folder is created, updated or deleted |
| Spaces | `spaceCreated`, `spaceUpdated`, `spaceDeleted` | A Space is created, updated or deleted |
| Goals | `goalCreated`, `goalUpdated`, `goalDeleted` | A Goal is created, updated or deleted |
| Targets | `keyResultCreated`, `keyResultUpdated`, `keyResultDeleted` | A Target (key result) is created, updated or deleted |

Some of these come with companions. ClickUp's payload pages show, for example, a `taskUpdated` sent alongside `taskStatusUpdated`, and `spaceUpdated` alongside `listCreated`, so one action in the app can produce more than one delivery. ClickUp also notes that adding an attachment to a task does not fire `taskUpdated`, while uploading an attachment to a task comment does.

Here is ClickUp's example `taskStatusUpdated` payload:

```json
{
  "event": "taskStatusUpdated",
  "history_items": [
    {
      "id": "2800787326392370170",
      "type": 1,
      "date": "1642736073330",
      "field": "status",
      "parent_id": "162641062",
      "data": { "status_type": "custom" },
      "source": null,
      "user": {
        "id": 183,
        "username": "John",
        "email": "john@company.com",
        "color": "#7b68ee",
        "initials": "J",
        "profilePicture": null
      },
      "before": { "status": "to do", "color": "#f9d900", "orderindex": 0, "type": "open" },
      "after": { "status": "in progress", "color": "#7C4DFF", "orderindex": 1, "type": "custom" }
    }
  ],
  "task_id": "1vj38vv",
  "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
}

```

Every body has `event` and `webhook_id`, plus the ID of the resource: `task_id`, `list_id`, `folder_id`, `space_id`, or `goal_id` with `key_result_id` for Targets. `history_items` appears only "if available". It is missing from Goal and Target events and from most create and delete events outside tasks, and when it is present, `before` and `after` change shape with `field`. `date` is milliseconds since the epoch as a string, and `user.id` is an integer. `taskTimeTrackedUpdated` also carries a top-level `data` object.

ClickUp adds a few typing notes: unset booleans, such as a Custom Field checkbox, may be `null` instead of `false`, and Custom Field values are not normalised.

## Setting up ClickUp webhooks

Create the webhook with a ClickUp API token. The `team_id` is your Workspace ID.

```bash
curl -X POST "https://api.clickup.com/api/v2/team/$CLICKUP_TEAM_ID/webhook" \
  -H "Authorization: $CLICKUP_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://example.com/webhooks/clickup",
    "events": ["taskCreated", "taskUpdated", "taskStatusUpdated", "taskCommentPosted"],
    "list_id": 9876
  }'

```

The response contains the webhook object, including its `id`, its `health` (`status` and `fail_count`) and its `secret`. Store the secret server-side, for example as `CLICKUP_WEBHOOK_SECRET`. `GET /api/v2/team/{team_id}/webhook` lists the webhooks created by the calling user, so read it with the same token.

ClickUp defaults to `https` if the endpoint has no protocol and warns that plain HTTP may stop being supported. There is no test-send button, so trigger a real change in a test List to check your endpoint.

## Securing ClickUp webhooks

Read `X-Signature`, compute an HMAC-SHA256 of the raw request body with the webhook's secret, encode it as hex, and compare in constant time. If verification fails, answer with a `400`, never a `401`, because a `401` suspends the webhook.

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

function verifyClickUpSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  const expected = crypto.createHmac('sha256', 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/clickup', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyClickUpSignature(req.body, req.get('x-signature'), process.env.CLICKUP_WEBHOOK_SECRET)) {
    // Not 401: ClickUp suspends a webhook immediately on a 401.
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.event. Persist the event, then process it asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

def verify_clickup_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.sha256).hexdigest()
    # Compare as bytes: compare_digest raises TypeError on a str with non-ASCII characters.
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# if not verify_clickup_signature(raw_body, request.headers.get("x-signature"),
#                                 os.environ["CLICKUP_WEBHOOK_SECRET"]):
#     return Response(status_code=400)  # never 401

```

The signature in ClickUp's docs, `f7bc83f430538424b13298e6aa6`, is shortened for illustration, so do not test against it. Use the secret `clickup-test-webhook-secret` and this exact body instead:

```
{"webhook_id":"7689a169-a000-4985-8676-6902b96d6627","event":"taskCreated","task_id":"c0j"}

```

The signature should be:

```
816cdc4127c908c60c729b11a0c2f2bad85f587001a517a06cb773e40660b9a7

```

> **Make ClickUp webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Signature` header, answers ClickUp well inside the 7-second limit, and durably queues every task, List and Goal event before your service sees it.

## ClickUp webhook limitations and pain points

### A `401` switches the webhook off

**The Problem:** One rejected request and ClickUp stops sending anything, without telling you.

**Why It Happens:** ClickUp says "returning a `401` HTTP status code for a webhook will immediately mark it as suspended", and a `410` does the same. Returning `401` for a bad signature is the default reflex in most frameworks and auth middleware, so a single mis-signed request, or a secret mix-up after you create a second webhook, takes the integration down.

**Workarounds:**

* Return `400` for a failed signature check, and make sure no auth middleware in front of the route answers `401`.
* Never answer `410` from a route that ClickUp calls unless you mean to stop the webhook.
* Re-activate a suspended webhook with `PUT /api/v2/webhook/{webhook_id}` and `"status": "active"`.

**How Hookdeck Can Help:** ClickUp talks to Hookdeck rather than to your service, so status codes from your application, including a stray `401` from middleware, never reach ClickUp.

### Failed events are gone for good

**The Problem:** Events sent during an outage never arrive, even after you recover.

**Why It Happens:** A delivery fails on any non-`2xx` response or after 7 seconds. ClickUp retries each event up to five times, then increments `fail_count` and drops it: "Failed events will not be resent." At a `fail_count` of 100 the webhook is suspended. The retry intervals are not published, and no notification is sent when health changes.

**Workarounds:**

* Acknowledge quickly and process asynchronously.
* Poll your webhooks' `health` from the API and alert on a rising `fail_count` or a `suspended` status.
* After an outage, reconcile from the API for the window you were down.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then retries against your service on a schedule you control and lets you replay any event, so your downtime does not count towards `fail_count` and nothing is dropped.

### No timestamp, and duplicates to handle

**The Problem:** A captured request can be replayed, and retries deliver the same event more than once.

**Why It Happens:** Only the body is signed and ClickUp sends no timestamp header. Retries after a timeout repeat the event, and some actions also produce companion events.

**Workarounds:**

* Deduplicate on `{webhook_id}:{history_item_id}`, which is the key ClickUp suggests.
* For events with no `history_items`, such as Goal and Target events, build a key from `event`, the resource ID and the time you received it, and keep handlers idempotent.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated deliveries before they reach your service, based on the fields you choose.

### One secret per registration

**The Problem:** Several webhooks pointing at one endpoint each sign with a different secret.

**Why It Happens:** Every registration gets its own secret, returned when it is created. There is no dashboard where you can look it up later.

**Workarounds:**

* Store each secret against its `webhook_id` when you create the webhook.
* Use a separate path per registration, or look up the secret by `webhook_id` from the body before verifying.

**How Hookdeck Can Help:** Give each ClickUp registration its own Hookdeck source and route them to one service. Each source holds its own secret, and your code only has to trust Hookdeck.

### Automation webhooks are a different thing

**The Problem:** Requests from a ClickUp Automation fail your signature check and do not match your event handler.

**Why It Happens:** The Automation "Call webhook" action posts a different body (`auto_id`, `trigger_id`, `date` and `payload`, with no `event`), and its documentation says nothing about `X-Signature`.

**Workarounds:**

* Point Automations at a separate route with its own authentication, such as a secret in the URL or a custom header.
* Keep API webhooks and Automation calls apart in your code.

**How Hookdeck Can Help:** Use a separate Hookdeck source for Automation calls, configured for whatever authentication you add, so it never mixes with your signed API webhooks.

## Best practices

### Verify against the raw body

The signature covers the exact bytes ClickUp sent. ClickUp's own note about re-stringifying a parsed body "without adding white spaces" is fragile. Read the raw body with `express.raw()`, `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, or `await request.body()` in FastAPI.

### Never return `401` or `410`

Make this a rule for the whole route, not just your verifier. Check what your framework, gateway and any auth middleware return for unauthenticated requests.

### Watch webhook health

ClickUp stays silent when a webhook fails or is suspended. Poll `GET /api/v2/team/{team_id}/webhook` with the token that created the webhooks and alert when `health.status` is not `active`.

### Keep the creating user active

A webhook stops firing when the user who created it is disabled. Create webhooks with a service account that will not leave the Workspace.

### Treat `history_items` as optional

Check for `history_items` before reading it, switch on `field` before reading `before` and `after`, and parse `date` as a string of milliseconds.

### Acknowledge fast and process asynchronously

Verify, persist, return `200`, and do the real work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

ClickUp signs every webhook with a hex HMAC-SHA256 of the raw body in `X-Signature`, keyed with that registration's secret. Verify the raw bytes, return `400` rather than `401` when verification fails, deduplicate on `webhook_id` and `history_item_id`, and watch `fail_count`, because ClickUp drops failed events and suspends webhooks without telling you.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every `X-Signature`, and holds each event until your service has processed it, so an outage on your side costs latency instead of dropped events and a suspended webhook.

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