# Guide to Plane Webhooks: Features and Best Practices

Plane is an open-source project management tool that organises work into projects, cycles, modules and work items. Its webhooks let other systems react when that work changes: posting to chat when an urgent bug is filed, mirroring work items into another tracker, or updating a report when a cycle closes.

In this guide: how Plane's v2 webhooks are scoped and filtered, the event catalogue and payload, `X-Plane-Signature` verification, and why a few failed deliveries can switch your webhook off.

## What are Plane webhooks?

Plane webhooks belong to the workspace, not to a project. One webhook receives events from every project in the workspace, and only workspace owners and admins can create or manage them. Each webhook has its own URL, event list, optional work-item filter and secret.

The current system is v2, with dot-notation event names such as `workitem.created` and fields for deduplication and diffs. Webhooks created before v2 are marked *(deprecated)* in the list. They still deliver, but without those fields, and there's no in-place upgrade: you recreate them as new webhooks.

## Plane webhook features

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-plane-signature` |
| Body | JSON |
| Event routing | Body field `event` |

From Plane's [webhook documentation](https://developers.plane.so/dev-tools/intro-webhooks):

* Payload URLs must be publicly reachable `http://` or `https://` addresses. Localhost and private IPs are refused.
* Work-item events can be filtered in **Basic** mode (dropdowns) or **PQL** mode (Plane Query Language), on type, state group, assignee, label, project, priority, start date and due date.
* Each request carries `X-Plane-Delivery`, `X-Plane-Event` and `X-Plane-Signature`, with `User-Agent: Autopilot`.
* Any `2xx` counts as success. Server errors and unavailability are retried with exponential backoff from a base of about 10 minutes, with jitter. `4xx` responses are not retried.
* After 5 failed attempts Plane disables the webhook and emails its creator.
* The webhook detail view shows delivery counts, success rate and a per-attempt log with response times. There's no button to retry a failed delivery.

## Events and payloads

| Event routing | Body field `event` |

### All Documented Events (43)

| Group | Events |
| --- | --- |
| Projects | `project.created`, `project.updated`, `project.archived`, `project.deleted` |
| Cycles | `cycle.created`, `cycle.updated`, `cycle.archived`, `cycle.deleted` |
| Modules | `module.created`, `module.updated`, `module.archived`, `module.deleted` |
| Milestones | `milestone.created`, `milestone.updated`, `milestone.deleted` |
| Pages | `page.created`, `page.updated`, `page.archived`, `page.deleted` |
| Page comments | `page.comment.created`, `page.comment.updated`, `page.comment.deleted` |
| Work items | `workitem.created`, `workitem.updated`, `workitem.archived`, `workitem.deleted` |
| Work item comments | `workitem.comment.created`, `workitem.comment.updated`, `workitem.comment.deleted` |
| Work item links | `workitem.link.created`, `workitem.link.updated`, `workitem.link.deleted` |
| Work item votes | `workitem.vote.created`, `workitem.vote.deleted` |
| Work item attachments | `workitem.attachment.created`, `workitem.attachment.updated`, `workitem.attachment.deleted` |
| Work item relations | `workitem.relation.created`, `workitem.relation.deleted` |
| Work item dependencies | `workitem.dependency.created`, `workitem.dependency.deleted` |
| Work item page links | `workitem.page_link.created`, `workitem.page_link.deleted` |

Every v2 body has the same envelope:

| Field | Description |
| --- | --- |
| `event` | Dot-notation event name (also in the `X-Plane-Event` header) |
| `event_id` | Stable across retries. Use it for deduplication |
| `delivery_id` | New UUID for each delivery attempt (also in `X-Plane-Delivery`) |
| `entity_id` / `entity_type` | The primary entity affected, e.g. `issue`, `cycle`, `issue_comment`, `issue_link` |
| `data` | Full entity object for create and update events; `{}` for delete events |
| `previous_attributes` | Previous values of changed fields on updates; the full record before deletion on deletes; `{}` otherwise |

For `workitem.comment.created`, the comment sits at `data.comment`, with `comment_stripped` holding the plain text. This is the example from Plane's docs:

```json
{
  "version": "v2",
  "delivery_id": "01ab9316-f978-4449-bad6-dce958be8454",
  "event_id": "0afa042d-92a9-4326-bdca-5ff5490dbf09",
  "entity_id": "088a83b9-a53f-4dda-b2bc-c860cf455997",
  "entity_type": "issue",
  "event": "workitem.comment.created",
  "webhook_id": "285f087b-e1e0-4f90-b9f4-0b720acfac04",
  "workspace_id": "d250cd44-fa71-42c2-b2b5-3c73227288fc",
  "data": {
    "id": "088a83b9-a53f-4dda-b2bc-c860cf455997",
    "name": "Webhook Test Work Item 2",
    "comment": {
      "id": "4797f841-c731-4e55-971f-d9cfe1938dfb",
      "access": "INTERNAL",
      "actor_id": "88fc36c8-73b0-4547-81c7-96b70f61835e",
      "issue_id": "088a83b9-a53f-4dda-b2bc-c860cf455997",
      "edited_at": null,
      "comment_stripped": "Webhook Test Comment"
    }
  },
  "previous_attributes": {}
}

```

Two of Plane's documented examples, for `workitem.updated` and `workitem.link.updated`, show `WebhookScope.ScopeChoices.WORK_ITEM_UPDATED` in `event` instead of the dot-notation key. It isn't documented whether live update deliveries do the same, so compare `event` with `X-Plane-Event` on your first real update and route on whichever is consistent. Plane only shows four example payloads for 43 events, so capture a real delivery for each event you handle.

## Setting up Plane webhooks

1. Go to **Workspace settings > Webhooks** and click **Add webhook**.
2. Enter a **Webhook title** and the **Payload URL**, for example `https://example.com/webhooks/plane`.
3. Tick the events you want. They're grouped by Projects, Cycles, Modules, Work items and so on.
4. Optionally open **Advanced configurations** and add a work-item filter, such as `priority in ["urgent", "high"]`.
5. Click **Create webhook**. The webhook is live straight away.

Plane doesn't show the secret on screen. It downloads a CSV containing the key, which starts with `plane_wh_`, when you click **Create webhook**. Store it as `PLANE_WEBHOOK_SECRET`. If you lose the file, open the webhook's **Edit** form and use the eye icon in **Secret key** to reveal it.

To receive through Hookdeck, create a Plane source with the secret and use its URL as the payload URL. Because Plane refuses localhost, use the Hookdeck CLI for local testing:

```bash
npx hookdeck-cli listen 3000 plane --path /webhooks/plane

```

To move a deprecated v1 webhook to v2, create a new webhook with the same URL and events, save the new secret, update your handler for the v2 envelope, then delete the old one.

## Securing Plane webhooks

`X-Plane-Signature` is the hex HMAC-SHA256 of the raw request body, keyed with the webhook secret. The whole header is the digest, with no prefix or timestamp. Compute it over the bytes you received before parsing JSON and compare in constant time.

### Node.js verification

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-plane-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

This handler deduplicates on `event_id` and only acknowledges after the event is stored. If storage fails it returns `503`, which Plane retries. A `4xx` would be treated as a deliberate rejection and dropped.

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

const app = express();

app.post('/webhooks/plane', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.PLANE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  let payload;
  try {
    payload = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return res.sendStatus(400);
  const eventName = req.headers['x-plane-event'] || payload.event;

  try {
    await savePlaneEvent(payload.event_id, eventName, payload.entity_type, payload);
  } catch {
    return res.sendStatus(503);
  }
  res.sendStatus(200);
});

```

When you click **Re-generate key**, the old key stops working immediately. Deploy the new key first, or deliveries will fail verification and, after five failures, disable the webhook.

Hookdeck's Plane source checks the same signature. It replies `200` to Plane, so failures there never count towards Plane's five-attempt limit, and records any mismatch as failed verification. No timestamp window is applied, since Plane signs none.

> **Make Plane webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) takes every Plane delivery, so your webhook isn't disabled during an outage, and gives you the manual retry Plane's own delivery log lacks.

## Plane webhook limitations and pain points

### Five failures disable the webhook

**The Problem:** A short outage or a deploy that breaks verification can switch the webhook off, and events during that time are lost.

**Why It Happens:** Plane disables a webhook after 5 failed attempts. Events aren't queued while it's disabled, so anything that happens before you re-enable it is never delivered.

**Workarounds:**

* Make sure the webhook creator's email is monitored, since that's who Plane notifies.
* After re-enabling, re-sync recently changed items from the Plane API to cover the gap.

**How Hookdeck Can Help:** Hookdeck accepts each delivery and retries your service on its own schedule, so Plane sees success and keeps the webhook enabled.

### No manual retry

**The Problem:** A delivery that exhausted its retries, or that you rejected with a `4xx`, can't be sent again.

**Why It Happens:** Plane's retries are automatic only, and `4xx` responses aren't retried at all.

**Workarounds:**

* Return `5xx` for temporary problems so Plane retries, and `4xx` only for requests you never want.
* Store the raw body before processing so you can rerun your own logic.

**How Hookdeck Can Help:** Hookdeck keeps every request and lets you replay any event or a filtered set of them to your service.

### Update events may not use dot-notation names

**The Problem:** Routing on `event` could miss work-item updates.

**Why It Happens:** Plane's own `workitem.updated` and `workitem.link.updated` examples show `WebhookScope.ScopeChoices.WORK_ITEM_UPDATED` in `event`, which also makes link updates look like work-item updates. Whether live deliveries do this is undocumented.

**Workarounds:**

* Read `X-Plane-Event` as well as `event`, and log any value you don't recognise.
* Use `entity_type` (such as `issue` or `issue_link`) to tell the two apart.

**How Hookdeck Can Help:** Hookdeck's request log shows the raw headers and body of real deliveries, so you can see which form Plane actually sends.

### A broken filter silently drops events

**The Problem:** Work-item events stop arriving with no error.

**Why It Happens:** Plane evaluates filters at delivery time, and if a filter fails to evaluate, the delivery is skipped rather than sent unfiltered.

**Workarounds:**

* Check the PQL expression in the edit form when expected deliveries don't show up.
* Keep filters simple, or subscribe without one and filter on your side.

**How Hookdeck Can Help:** Subscribe without a Plane filter and use Hookdeck filters on `data` fields instead, so the matching rule is visible and testable.

## Best practices

### Deduplicate on event_id

`event_id` stays the same across retries, while `delivery_id` changes with each attempt. Store `event_id` with a unique constraint. Our guide to [webhook idempotency](/webhooks/guides/implement-webhook-idempotency) covers the patterns.

### Use previous_attributes for diffs

On updates, `previous_attributes` holds only the fields that changed, so you can react to a state or priority change without fetching the old record. On deletes it holds the full record, because `data` is empty.

### Respond before doing the work

Plane doesn't state a timeout, but slow responses still risk failures and retries. Store the event, return `200`, then process it in a background job. See [why webhooks should be processed asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Recreate v1 webhooks

Deprecated webhooks lack `event_id` and `previous_attributes`, so they can't be deduplicated reliably. Replace them with v2 webhooks.

## Conclusion

Plane webhooks cover a whole workspace, with 43 v2 events, optional PQL filtering for work items and a hex HMAC-SHA256 in `X-Plane-Signature`. The details that cause trouble are on the delivery side: five failures disable the webhook, events during that time are dropped, and there's no manual retry.

[Hookdeck Event Gateway](https://hookdeck.com) keeps Plane's webhook enabled through your outages and stores each event so you can replay it.

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