Gareth Wilson Gareth Wilson

Guide to ClickUp Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI only: POST /api/v2/team/{team_id}/webhook. There is no UI for API webhooks
OwnershipTied to the user whose token created the webhook
Subscription modelA list of events, or * for all, per registration
Location filterAt most one of space_id, folder_id, list_id, task_id. The most specific applies
Signature headerX-Signature
SchemeHMAC-SHA256, hex digest
Signed contentThe raw request body, nothing else
SecretOne per registration, returned as webhook.secret
Replay windowNone. No timestamp is sent or signed
Response window7 seconds. Any non-2xx is a failure
RetriesUp to five per event, then the event is dropped and fail_count goes up
SuspensionAt fail_count 100, or immediately on a 401 or 410
NotificationsNone when a webhook's health changes
Source IPsNo 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.

AreaEventsFires when
TaskstaskCreated, taskUpdated, taskDeletedA task is created, updated or deleted
Task fieldstaskPriorityUpdated, taskStatusUpdated, taskAssigneeUpdated, taskDueDateUpdated, taskTagUpdatedA task's priority, status, assignees, due date or tags change
Task activitytaskMoved, taskCommentPosted, taskCommentUpdatedA task moves to another List, or a comment is added or edited
TimetaskTimeEstimateUpdated, taskTimeTrackedUpdatedA time estimate is set or changed, or tracked time is added, updated or deleted
ListslistCreated, listUpdated, listDeletedA List is created, updated or deleted
FoldersfolderCreated, folderUpdated, folderDeletedA Folder is created, updated or deleted
SpacesspaceCreated, spaceUpdated, spaceDeletedA Space is created, updated or deleted
GoalsgoalCreated, goalUpdated, goalDeletedA Goal is created, updated or deleted
TargetskeyResultCreated, keyResultUpdated, keyResultDeletedA 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:

{
  "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.

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.

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:

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 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 and our guide to 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 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 for free and handle ClickUp webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.