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:
- Payload URLs must be publicly reachable
http://orhttps://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-EventandX-Plane-Signature, withUser-Agent: Autopilot. - Any
2xxcounts as success. Server errors and unavailability are retried with exponential backoff from a base of about 10 minutes, with jitter.4xxresponses 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_, workitem.page_ |
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:
{
"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_ 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
- Go to Workspace settings > Webhooks and click Add webhook.
- Enter a Webhook title and the Payload URL, for example
https://example.com/webhooks/plane. - Tick the events you want. They're grouped by Projects, Cycles, Modules, Work items and so on.
- Optionally open Advanced configurations and add a work-item filter, such as
priority in ["urgent", "high"]. - 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
5xxfor temporary problems so Plane retries, and4xxonly 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_ in event, which also makes link updates look like work-item updates. Whether live deliveries do this is undocumented.
Workarounds:
- Read
X-Plane-Eventas well asevent, and log any value you don't recognise. - Use
entity_type(such asissueorissue_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.