Gareth Wilson Gareth Wilson

Guide to Plane Webhooks: Features and Best Practices

Published


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

FeatureDetails
Signaturehex HMAC-SHA256
Signature headerx-plane-signature
BodyJSON
Event routingBody field event

From Plane's webhook documentation:

  • 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)

GroupEvents
Projectsproject.created, project.updated, project.archived, project.deleted
Cyclescycle.created, cycle.updated, cycle.archived, cycle.deleted
Modulesmodule.created, module.updated, module.archived, module.deleted
Milestonesmilestone.created, milestone.updated, milestone.deleted
Pagespage.created, page.updated, page.archived, page.deleted
Page commentspage.comment.created, page.comment.updated, page.comment.deleted
Work itemsworkitem.created, workitem.updated, workitem.archived, workitem.deleted
Work item commentsworkitem.comment.created, workitem.comment.updated, workitem.comment.deleted
Work item linksworkitem.link.created, workitem.link.updated, workitem.link.deleted
Work item votesworkitem.vote.created, workitem.vote.deleted
Work item attachmentsworkitem.attachment.created, workitem.attachment.updated, workitem.attachment.deleted
Work item relationsworkitem.relation.created, workitem.relation.deleted
Work item dependenciesworkitem.dependency.created, workitem.dependency.deleted
Work item page linksworkitem.page_link.created, workitem.page_link.deleted

Every v2 body has the same envelope:

FieldDescription
eventDot-notation event name (also in the X-Plane-Event header)
event_idStable across retries. Use it for deduplication
delivery_idNew UUID for each delivery attempt (also in X-Plane-Delivery)
entity_id / entity_typeThe primary entity affected, e.g. issue, cycle, issue_comment, issue_link
dataFull entity object for create and update events; {} for delete events
previous_attributesPrevious 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:

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

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

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.

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

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 keeps Plane's webhook enabled through your outages and stores each event so you can replay it.

Get started with Hookdeck for free and handle Plane 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.