$npx -y skills add hookdeck/webhook-skills --skill linear-webhooksReceive and verify Linear webhooks. Use when setting up Linear webhook handlers, debugging Linear signature verification, or handling Linear issue tracking events like Issue, Comment, Project, Cycle, IssueLabel, and IssueSLA create/update/remove actions.
| 1 | # Linear Webhooks |
| 2 | |
| 3 | ## When to Use This Skill |
| 4 | |
| 5 | - Setting up Linear webhook handlers |
| 6 | - Debugging Linear signature verification failures |
| 7 | - Validating the `Linear-Signature` HMAC-SHA256 header |
| 8 | - Handling Linear `Issue`, `Comment`, `Project`, `Cycle`, `IssueLabel`, or `IssueSLA` events |
| 9 | - Reacting to `create`, `update`, and `remove` actions on Linear entities |
| 10 | - Rejecting stale webhook deliveries via the `webhookTimestamp` field |
| 11 | |
| 12 | ## Essential Code (USE THIS) |
| 13 | |
| 14 | ### Linear Signature Verification (JavaScript) |
| 15 | |
| 16 | Linear signs each webhook with **HMAC-SHA256** over the **raw request body**, hex-encoded, sent in the `Linear-Signature` header. Linear has no first-party Node SDK helper for verifying webhooks, so manual verification is the recommended approach. |
| 17 | |
| 18 | ```javascript |
| 19 | const crypto = require('crypto'); |
| 20 | |
| 21 | function verifyLinearWebhook(rawBody, signatureHeader, secret) { |
| 22 | if (!signatureHeader || !secret) return false; |
| 23 | |
| 24 | // HMAC-SHA256(rawBody, secret) → hex |
| 25 | const expected = crypto |
| 26 | .createHmac('sha256', secret) |
| 27 | .update(rawBody) |
| 28 | .digest('hex'); |
| 29 | |
| 30 | try { |
| 31 | return crypto.timingSafeEqual( |
| 32 | Buffer.from(signatureHeader, 'hex'), |
| 33 | Buffer.from(expected, 'hex') |
| 34 | ); |
| 35 | } catch { |
| 36 | return false; |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | // Reject deliveries older than 1 minute (replay protection) |
| 41 | function isFreshTimestamp(webhookTimestamp) { |
| 42 | if (typeof webhookTimestamp !== 'number') return false; |
| 43 | const skewMs = Math.abs(Date.now() - webhookTimestamp); |
| 44 | return skewMs <= 60 * 1000; |
| 45 | } |
| 46 | ``` |
| 47 | |
| 48 | ### Express Webhook Handler |
| 49 | |
| 50 | ```javascript |
| 51 | const express = require('express'); |
| 52 | const app = express(); |
| 53 | |
| 54 | // CRITICAL: Use express.raw() - Linear signs the raw body |
| 55 | app.post('/webhooks/linear', |
| 56 | express.raw({ type: 'application/json' }), |
| 57 | (req, res) => { |
| 58 | const signature = req.headers['linear-signature']; |
| 59 | const event = req.headers['linear-event']; // e.g. "Issue", "Comment" |
| 60 | const delivery = req.headers['linear-delivery']; // UUID for idempotency |
| 61 | |
| 62 | if (!verifyLinearWebhook(req.body, signature, process.env.LINEAR_WEBHOOK_SECRET)) { |
| 63 | return res.status(400).send('Invalid signature'); |
| 64 | } |
| 65 | |
| 66 | const payload = JSON.parse(req.body.toString()); |
| 67 | |
| 68 | // Linear requires rejecting deliveries older than 1 minute |
| 69 | if (!isFreshTimestamp(payload.webhookTimestamp)) { |
| 70 | return res.status(400).send('Stale webhook'); |
| 71 | } |
| 72 | |
| 73 | console.log(`Linear ${event} ${payload.action} (delivery: ${delivery})`); |
| 74 | |
| 75 | switch (event) { |
| 76 | case 'Issue': |
| 77 | console.log(`Issue ${payload.action}:`, payload.data?.title); |
| 78 | break; |
| 79 | case 'Comment': |
| 80 | console.log(`Comment ${payload.action} on issue ${payload.data?.issueId}`); |
| 81 | break; |
| 82 | case 'Project': |
| 83 | console.log(`Project ${payload.action}:`, payload.data?.name); |
| 84 | break; |
| 85 | case 'IssueSLA': |
| 86 | console.log(`SLA event on issue ${payload.issueData?.id}`); |
| 87 | break; |
| 88 | default: |
| 89 | console.log(`Unhandled Linear event: ${event}`); |
| 90 | } |
| 91 | |
| 92 | res.status(200).send('OK'); |
| 93 | } |
| 94 | ); |
| 95 | ``` |
| 96 | |
| 97 | ### Python Signature Verification (FastAPI) |
| 98 | |
| 99 | ```python |
| 100 | import hmac |
| 101 | import hashlib |
| 102 | import time |
| 103 | |
| 104 | def verify_linear_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool: |
| 105 | if not signature_header or not secret: |
| 106 | return False |
| 107 | expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() |
| 108 | return hmac.compare_digest(signature_header, expected) |
| 109 | |
| 110 | |
| 111 | def is_fresh_timestamp(webhook_timestamp_ms: int) -> bool: |
| 112 | if not isinstance(webhook_timestamp_ms, int): |
| 113 | return False |
| 114 | now_ms = int(time.time() * 1000) |
| 115 | return abs(now_ms - webhook_timestamp_ms) <= 60_000 |
| 116 | ``` |
| 117 | |
| 118 | > **For complete working examples with tests**, see: |
| 119 | > - [examples/express/](examples/express/) - Full Express implementation |
| 120 | > - [examples/nextjs/](examples/nextjs/) - Next.js App Router implementation |
| 121 | > - [examples/fastapi/](examples/fastapi/) - Python FastAPI implementation |
| 122 | |
| 123 | ## Common Linear-Event Header Values |
| 124 | |
| 125 | | `Linear-Event` | Triggered When | |
| 126 | |----------------|----------------| |
| 127 | | `Issue` | Issue created, updated, or removed | |
| 128 | | `Comment` | Comment created, updated, or removed | |
| 129 | | `IssueLabel` | Label created, updated, or removed | |
| 130 | | `Project` | Project created, updated, or removed | |
| 131 | | `ProjectUpdate` | Project update posted | |
| 132 | | `Cycle` | Cycle created, updated, or removed | |
| 133 | | `Reaction` | Reaction added or removed | |
| 134 | | `Document` | Document created, updated, or removed | |
| 135 | | `Initiative` | Initiative created, updated, or removed | |
| 136 | | `InitiativeUpdate` | Initiative update posted | |
| 137 | | `Customer` | Customer record changed | |
| 138 | | `CustomerRequ |