Skip to content

Commit e01aaa0

Browse files
committed
review fixes
1 parent cdaabbc commit e01aaa0

2 files changed

Lines changed: 112 additions & 49 deletions

File tree

docs/dev-tools/intro-webhooks.md

Lines changed: 112 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,22 @@ Webhooks in Plane work at the workspace level. A single webhook can subscribe to
1414

1515
The current webhook system is v2. V2 payloads use dot-notation event names (e.g., `workitem.created`) and include structured fields for deduplication, diffing, and filtering that were not available in the original version. If you have webhooks created before v2, they appear in the list with a **(deprecated)** tag. They still deliver but do not receive any v2 fields. Recreate them as new webhooks to get the full v2 feature set.
1616

17-
## Creating a webhook
18-
19-
### What you're configuring
20-
21-
When you create a webhook, you're telling Plane two things: where to send events, and which events to send.
22-
23-
**Webhook title** is a label for your own reference - it appears in the webhook list and helps you tell multiple webhooks apart.
17+
:::warning Migrate your v1 webhooks to v2
18+
V1 webhooks are deprecated. They continue to deliver but will not receive v2 payload features — including `delivery_id` and `event_id` for deduplication, `previous_attributes` for diffs on updated events, and dot-notation event names. There is no in-place upgrade. To move to v2, recreate each v1 webhook as a new webhook and update your server to handle the v2 payload structure.
2419

25-
**Payload URL** is the endpoint that Plane will POST to. It must be a publicly reachable `http://` or `https://` address. Local addresses (localhost, private IPs) are not accepted.
20+
**To migrate a v1 webhook:**
2621

27-
**Events** control what triggers this webhook. The form groups events by type - Projects, Cycles, Modules, Work items, and so on. Check the specific actions you care about. You can subscribe to as many or as few as you need.
22+
1. Open the v1 webhook (marked **deprecated**) and note its URL and event subscriptions.
23+
2. Create a new webhook with the same URL and event subscriptions. See [How to create a webhook](#how-to-create-a-webhook).
24+
3. Save the new secret key from the CSV download — your server will need it to verify v2 requests.
25+
4. Update your server to expect the v2 payload structure. See [Payload structure](#payload-structure) for the full field reference.
26+
5. Test that deliveries are arriving and your server is handling them correctly.
27+
6. Delete the original v1 webhook once you're confident the new one is working.
28+
:::
2829

29-
**Advanced configurations** lets you add a filter so the webhook only fires for work items that match specific conditions - for example, high-priority bugs in a particular project. See [Filtering work item events](#filtering-work-item-events) below.
30+
## Creating a webhook
3031

31-
**Secret key** is generated automatically when you save the webhook. Plane downloads it as a CSV file the moment you click **Create webhook** and then returns you to the webhook list. It is not displayed on screen - the download is the only time you receive it automatically. Save the file. You need the key to verify incoming requests.
32+
![Plane architecture](/images/webhooks/create-webhook.webp#hero)
3233

3334
### How to create a webhook
3435

@@ -43,6 +44,20 @@ Plane downloads the secret key as a CSV file to your computer and returns you to
4344

4445
If you lose the CSV, you can re-generate the secret key from the edit form - but the old key stops working the moment you do.
4546

47+
### What you're configuring
48+
49+
When you create a webhook, you're telling Plane two things: where to send events, and which events to send.
50+
51+
- **Webhook title** is a label for your own reference - it appears in the webhook list and helps you tell multiple webhooks apart.
52+
53+
- **Payload URL** is the endpoint that Plane will POST to. It must be a publicly reachable `http://` or `https://` address. Local addresses (localhost, private IPs) are not accepted.
54+
55+
- **Events** control what triggers this webhook. The form groups events by type - Projects, Cycles, Modules, Work items, and so on. Check the specific actions you care about. You can subscribe to as many or as few as you need.
56+
57+
- **Advanced configurations** lets you add a filter so the webhook only fires for work items that match specific conditions - for example, high-priority bugs in a particular project. See [Filtering work item events](#filtering-work-item-events) below.
58+
59+
- **Secret key** is generated automatically when you save the webhook. Plane downloads it as a CSV file the moment you click **Create webhook** and then returns you to the webhook list. It is not displayed on screen - the download is the only time you receive it automatically. Save the file. You need the key to verify incoming requests.
60+
4661
## Filtering work item events
4762

4863
### How filtering works
@@ -66,7 +81,7 @@ Switching between modes is lossless - your filter is not lost when you switch.
6681

6782
1. Create or edit a webhook.
6883
2. Check at least one **Work items** event.
69-
3. Expand **Advanced configurations**.
84+
3. Scroll down to the **Work item v2 filters** section.
7085
4. Use the filter builder to define your conditions in Basic mode, or switch to PQL mode to type an expression directly.
7186
5. Save the webhook.
7287

@@ -93,43 +108,6 @@ assignee_id = "<user-uuid>" Specific assignee
93108
project_id = "<project-uuid>" Specific project
94109
```
95110

96-
## Securing requests
97-
98-
### Why Plane signs every request
99-
100-
Any server on the internet can send a POST request to your endpoint. Without a way to verify the source, someone could send fake webhook payloads to your system and trigger whatever logic you've built around them.
101-
102-
Plane solves this by signing every request with HMAC-SHA256 using your secret key. The signature is attached as an `X-Plane-Signature` header. Because only Plane and you know the secret, a valid signature proves the request came from Plane and was not modified in transit.
103-
104-
Skipping verification means your endpoint will process any request that arrives - forged or not.
105-
106-
### How to verify a webhook payload
107-
108-
On your server, compute the expected signature from the **raw request body bytes** and compare it to the value in `X-Plane-Signature`. Use a constant-time comparison to prevent timing attacks.
109-
110-
```python
111-
import hashlib
112-
import hmac
113-
114-
def verify_webhook(request_body_bytes: bytes, secret: str, signature_header: str) -> bool:
115-
expected = hmac.new(
116-
secret.encode("utf-8"),
117-
request_body_bytes,
118-
hashlib.sha256,
119-
).hexdigest()
120-
return hmac.compare_digest(expected, signature_header)
121-
```
122-
123-
Use the raw bytes from the incoming request - not a parsed or re-serialized version. JSON re-serialization can change key ordering, spacing, or escaping, which will produce a different signature and cause verification to fail. Reject any request where the signature does not match before running any other logic.
124-
125-
### Signature header reference
126-
127-
| Header | Value |
128-
| ------------------- | ------------------------------------------------------------------------------ |
129-
| `X-Plane-Signature` | HMAC-SHA256 hex digest of the raw request body, keyed with your webhook secret |
130-
131-
The secret key is formatted as `plane_wh_` followed by a random string. Plane masks it in the UI. To view the full key, open the edit form for the webhook and use the show/hide toggle in the **Secret key** section.
132-
133111
## Managing webhooks
134112

135113
### Disabling versus deleting
@@ -175,6 +153,43 @@ Re-generate if your secret key is compromised. The old key is invalidated the mo
175153

176154
Plane downloads the new key as a CSV.
177155

156+
## Securing requests
157+
158+
### Why Plane signs every request
159+
160+
Any server on the internet can send a POST request to your endpoint. Without a way to verify the source, someone could send fake webhook payloads to your system and trigger whatever logic you've built around them.
161+
162+
Plane solves this by signing every request with HMAC-SHA256 using your secret key. The signature is attached as an `X-Plane-Signature` header. Because only Plane and you know the secret, a valid signature proves the request came from Plane and was not modified in transit.
163+
164+
Skipping verification means your endpoint will process any request that arrives - forged or not.
165+
166+
### How to verify a webhook payload
167+
168+
On your server, compute the expected signature from the **raw request body bytes** and compare it to the value in `X-Plane-Signature`. Use a constant-time comparison to prevent timing attacks.
169+
170+
```python
171+
import hashlib
172+
import hmac
173+
174+
def verify_webhook(request_body_bytes: bytes, secret: str, signature_header: str) -> bool:
175+
expected = hmac.new(
176+
secret.encode("utf-8"),
177+
request_body_bytes,
178+
hashlib.sha256,
179+
).hexdigest()
180+
return hmac.compare_digest(expected, signature_header)
181+
```
182+
183+
Use the raw bytes from the incoming request - not a parsed or re-serialized version. JSON re-serialization can change key ordering, spacing, or escaping, which will produce a different signature and cause verification to fail. Reject any request where the signature does not match before running any other logic.
184+
185+
### Signature header reference
186+
187+
| Header | Value |
188+
| ------------------- | ------------------------------------------------------------------------------ |
189+
| `X-Plane-Signature` | HMAC-SHA256 hex digest of the raw request body, keyed with your webhook secret |
190+
191+
The secret key is formatted as `plane_wh_` followed by a random string. Plane masks it in the UI. To view the full key, open the edit form for the webhook and use the show/hide toggle in the **Secret key** section.
192+
178193
## Delivery and monitoring
179194

180195
### How Plane delivers events
@@ -361,3 +376,51 @@ All v2 payloads share this top-level structure:
361376
"previous_attributes": {}
362377
}
363378
```
379+
380+
**workitem.link.updated**
381+
382+
```json
383+
{
384+
"version":"v2",
385+
"delivery_id":"2a0d0510-9052-446e-a1c7-a704bbd68cba",
386+
"event_id":"9d508cd9-36c2-44a5-928d-7ee2f2a3b8a8",
387+
"entity_id":"775c5716-5302-4617-bb9f-2cd843911268",
388+
"entity_type":"issue",
389+
"event":"WebhookScope.ScopeChoices.WORK_ITEM_UPDATED",
390+
"webhook_id":"8944ed18-1331-4eae-b9bb-7c40864b8abd",
391+
"workspace_id":"b54ecb0d-e3eb-4986-b238-f83fd8665e65",
392+
"data":{
393+
"id":"775c5716-5302-4617-bb9f-2cd843911268",
394+
"name":"webhook test 3",
395+
"point":"None",
396+
"type_id":"None",
397+
"is_draft":false,
398+
"priority":"none",
399+
"state_id":"067b88e5-304b-4221-ba09-94340dcc36e5",
400+
"label_ids":[],
401+
"parent_id":"None",
402+
"created_at":"2026-03-31T11:44:41.249292+00:00",
403+
"deleted_at":"None",
404+
"project_id":"59e3be42-87ec-4950-99a3-ae639cf2b089",
405+
"sort_order":75535,
406+
"start_date":"None",
407+
"updated_at":"2026-03-31T11:44:41.249304+00:00",
408+
"archived_at":"None",
409+
"external_id":"None",
410+
"sequence_id":3,
411+
"target_date":"None",
412+
"assignee_ids":[ ],
413+
"completed_at":"None",
414+
"workspace_id":"b54ecb0d-e3eb-4986-b238-f83fd8665e65",
415+
"created_by_id":"754009ab-3fb5-424e-909a-b46e9c9d0c4f",
416+
"updated_by_id":"None",
417+
"external_source":"None",
418+
"description_json":{},
419+
"last_activity_at":"2026-03-31T11:44:41.346305+00:00",
420+
"estimate_point_id":"None"
421+
},
422+
"previous_attributes":{
423+
"last_activity_at":"2026-03-31 11:44:41.242868+00"
424+
}
425+
}
426+
```
63.6 KB
Loading

0 commit comments

Comments
 (0)