# Plane Webhooks

## When to Use This Skill

* How do I receive Plane webhooks?
* How do I verify the `X-Plane-Signature` header?
* Why is my Plane webhook signature verification failing?
* How do I handle `workitem.created`, `workitem.updated`, `workitem.comment.created`, `project.updated` or `cycle.created` events from Plane?
* How do I deduplicate Plane webhook deliveries (`event_id` vs `delivery_id`)?

## Verification (core)

Plane signs the **raw request body** with HMAC-SHA256 keyed on the webhook **secret key** (`plane_wh_...`, used as plain UTF-8 bytes). `X-Plane-Signature` is the bare hex digest: no prefix, and nothing else (no timestamp or ID) is signed. Check the header is exactly 64 hex characters, then compare in constant time. Reject a missing, malformed or mismatched signature with `401`.

Node:

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

function verify(rawBody, signature, secret) {
  if (typeof signature !== 'string' || !/^[0-9a-fA-F]{64}$/.test(signature) || !secret) return false;
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(rawBody).digest();
  const received = Buffer.from(signature.toLowerCase(), 'hex'); // safe: format validated above
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

```

Python:

```python
import hmac, hashlib, re

def verify(raw_body: bytes, signature: str | None, secret: str | None) -> bool:
    if not signature or not secret or not re.fullmatch(r"[0-9a-fA-F]{64}", signature, re.ASCII):
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("ascii"), expected.encode("ascii"))

```

`rawBody` must be the exact bytes received (a `Buffer`, not a parsed or re-serialized object). In Express use `express.raw({ type: '*/*' })`; in Next.js use `await request.arrayBuffer()`; in FastAPI use `await request.body()`. Parse the JSON only after the check passes.

> **For complete handlers with route wiring, event dispatch, and tests**, see:
> 
> * [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/plane-webhooks/examples/express/)
> * [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/plane-webhooks/examples/nextjs/)
> * [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/plane-webhooks/examples/fastapi/)

## Common Event Types

Dispatch on the body's `event` field (dot notation); the same value is sent in the `X-Plane-Event` header.

| Event | Description |
| --- | --- |
| `workitem.created` | A work item is created |
| `workitem.updated` | A work item is updated (`previous_attributes` has the old values) |
| `workitem.deleted` | A work item is deleted (`data` is `{}`) |
| `workitem.comment.created` | A comment is added to a work item |
| `project.created` / `project.updated` | A project is created or updated |
| `cycle.created` / `cycle.updated` | A cycle is created or updated |

Plane documents 43 events across projects, cycles, modules, milestones, pages, page comments, work items, comments, links, votes, attachments, relations, dependencies and page links. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/plane-webhooks/references/overview.md) for the full list.

> **Full event reference**: [Plane webhooks](https://developers.plane.so/dev-tools/intro-webhooks)

## Important Headers

| Header | Description |
| --- | --- |
| `X-Plane-Signature` | Hex HMAC-SHA256 of the raw body (no prefix) |
| `X-Plane-Event` | Event type, e.g. `workitem.created` (matches `event` in the body) |
| `X-Plane-Delivery` | UUID for this delivery attempt (matches `delivery_id` in the body) |

## Delivery and Retries

* Any `2xx` is a success. A `5xx` or unreachable endpoint is retried with exponential backoff (about 10 minutes base, with jitter). After 5 failed attempts Plane **disables the webhook** and emails its creator.
* `4xx` responses are not retried, so a `401` for a bad signature ends that delivery.
* `event_id` is stable across retries (deduplicate on it); `delivery_id` is new for each attempt.
* No timestamp is signed, so there is no replay window to check.

## Environment Variables

```bash
PLANE_WEBHOOK_SECRET=plane_wh_your_secret_here   # The webhook's secret key, from the CSV download or the edit form

```

## Local Development

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

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/plane-webhooks/references/overview.md) - Plane webhook concepts, events and payload structure
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/plane-webhooks/references/setup.md) - Creating the webhook and getting the secret key
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/plane-webhooks/references/verification.md) - Signature verification details and gotchas