Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Configure and handle Linear webhooks for real-time event processing. Use when setting up webhooks, handling issue/project/cycle events, or building real-time integrations with Linear. Trigger: "linear webhooks", "linear events", "linear real-time", "handle linear webhook", "linear webhook setup", "linear webhook payload".
.claude/skills/jeremylongshore-linear-webhooks-events/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 6% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 83% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 50% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 131% | 0% |
| case-15 | ✗→✓ | ▲ Improved | -1% | 0% |
Set up and handle Linear webhooks for real-time event processing. Linear sends HTTP POST requests for data changes on Issues, Comments, Issue Attachments, Documents, Emoji Reactions, Projects, Project Updates, Cycles, Labels, Users, and Issue SLAs.
Webhook headers:
Linear-Signature — HMAC-SHA256 hex digest of the raw bodyLinear-Delivery — Unique delivery ID for deduplicationLinear-Event — Event type (e.g., "Issue")Content-Type: application/json; charset=utf-8Payload body includes: action, type, data, url, actor, updatedFrom (previous values on update), createdAt, webhookTimestamp (UNIX ms).
typescriptimport express from "express"; import crypto from "crypto"; const app = express(); // CRITICAL: use raw body parser — JSON parsing destroys the original for signature verification app.post("/webhooks/linear", express.raw({ type: "*/*" }), (req, res) => { const signature = req.headers["linear-signature"] as string; const delivery = req.headers["linear-delivery"] as string; const eventType = req.headers["linear-event"] as string; const rawBody = req.body.toString(); // 1. Verify HMAC-SHA256 signature const expected = crypto .createHmac("sha256", process.env.LINEAR_WEBHOOK_SECRET!) .update(rawBody) .digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { console.error(`Invalid signature for delivery ${delivery}`); return res.status(401).json({ error: "Invalid signature" }); } // 2. Parse and verify timestamp (guard against replay attacks) const event = JSON.parse(rawBody); const age = Date.now() - event.webhookTimestamp; if (age > 60000) { return res.status(400).json({ error: "Webhook expired" }); } // 3. Respond 200 immediately, process asynchronously res.json({ received: true }); processEvent(event, delivery).catch(err => console.error(`Failed processing ${delivery}:`, err) ); }); app.listen(3000, () => console.log("Webhook server on :3000"));
typescriptinterface LinearWebhookPayload { action: "create" | "update" | "remove"; type: string; // "Issue", "Comment", "Project", "Cycle", "IssueLabel", etc. data: Record<string, any>; url: string; actor?: { id: string; type: string; // "user", "application" name?: string; }; updatedFrom?: Record<string, any>; // Only contains fields that changed createdAt: string; webhookTimestamp: number; }
typescripttype Handler = (event: LinearWebhookPayload) => Promise<void>; const handlers: Record<string, Record<string, Handler>> = { Issue: { create: async (e) => { console.log(`New issue: ${e.data.identifier} — ${e.data.title}`); console.log(` Priority: ${e.data.priority}, Team: ${e.data.team?.key}`); // e.g., notify Slack, sync to external system }, update: async (e) => { // updatedFrom contains ONLY the fields that changed if (e.updatedFrom?.stateId) { console.log(`${e.data.identifier} state -> ${e.data.state?.name}`); if (e.data.state?.type === "completed") { await notifySlack(`Done: ${e.data.identifier} ${e.data.title}`); } } if (e.updatedFrom?.assigneeId) { console.log(`${e.data.identifier} assigned to ${e.data.assignee?.name}`); } if (e.updatedFrom?.priority !== undefined) { console.log(`${e.data.identifier} priority changed to ${e.data.priority}`); } }, remove: async (e) => { console.log(`Issue deleted: ${e.data.identifier}`); }, }, Comment: { create: async (e) => { console.log(`Comment on ${e.data.issue?.identifier}: ${e.data.body?.substring(0, 100)}`); }, }, Project: { update: async (e) => { if (e.updatedFrom?.state) { console.log(`Project "${e.data.name}" -> ${e.data.state}`); } }, }, Cycle: { update: async (e) => { if (e.updatedFrom?.completedAt && e.data.completedAt) { console.log(`Cycle "${e.data.name}" completed`); } }, }, ProjectUpdate: { create: async (e) => { // e.data includes diffMarkdown showing changes since last update console.log(`Project update: ${e.data.body?.substring(0, 100)}`); }, }, }; async function processEvent(event: LinearWebhookPayload, deliveryId: string): Promise<void> { const handler = handlers[event.type]?.[event.action]; if (handler) { await handler(event); } else { console.log(`Unhandled: ${event.type}.${event.action} (delivery: ${deliveryId})`); } }
Linear may retry failed deliveries. Deduplicate using the Linear-Delivery header.
typescript// In-memory for simple apps; use Redis/DB for distributed systems const processedDeliveries = new Set<string>(); const MAX_TRACKED = 10000; function isDuplicate(deliveryId: string): boolean { if (processedDeliveries.has(deliveryId)) return true; processedDeliveries.add(deliveryId); if (processedDeliveries.size > MAX_TRACKED) { const entries = [...processedDeliveries]; entries.slice(0, MAX_TRACKED / 2).forEach(id => processedDeliveries.delete(id)); } return false; } // In webhook handler, after signature verification: if (isDuplicate(delivery)) { return res.json({ status: "duplicate, skipped" }); }
bash# Via Linear UI: # Settings > API > Webhooks > New webhook # URL: https://your-app.com/webhooks/linear # Resource types: Issues, Comments, Projects, Cycles # Teams: All public teams (or select specific ones) # Via GraphQL API: curl -X POST https://api.linear.app/graphql \ -H "Authorization: $LINEAR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "mutation { webhookCreate(input: { url: \"https://your-app.com/webhooks/linear\", resourceTypes: [\"Issue\", \"Comment\", \"Project\", \"Cycle\"], allPublicTeams: true }) { success webhook { id enabled secret } } }" }'
typescriptimport { LinearClient } from "@linear/sdk"; const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY! }); // List all webhooks const webhooks = await client.webhooks(); for (const wh of webhooks.nodes) { console.log(`${wh.url} — enabled: ${wh.enabled}, types: ${wh.resourceTypes?.join(", ")}`); } // Disable a webhook await client.updateWebhook("webhook-id", { enabled: false }); // Delete a webhook await client.deleteWebhook("webhook-id");
bash# Terminal 1: Start webhook server npm run dev # Terminal 2: Expose port 3000 ngrok http 3000 # Copy the https://xxxx.ngrok-free.app URL # Register in Linear Settings > API > Webhooks > New webhook # URL: https://xxxx.ngrok-free.app/webhooks/linear
| Error | Cause | Solution | |-------|-------|----------| | 401 Invalid signature | Wrong secret or body parsed as JSON | Use express.raw(), verify secret matches Linear | | Webhook not received | URL not publicly accessible | Check HTTPS, firewall rules, ngrok tunnel | | Duplicate processing | Linear retried delivery | Deduplicate using Linear-Delivery header | | Handler timeout | Processing takes too long | Respond 200 immediately, process async | | Missing updatedFrom | Field didn't change | updatedFrom only contains changed field keys | | actor is null | System-triggered event | Check actor.type before accessing .name |
typescriptasync function notifySlack(message: string) { await fetch(process.env.SLACK_WEBHOOK_URL!, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: message }), }); } // In Issue.update handler: if (e.updatedFrom?.stateId && e.data.state?.type === "completed") { await notifySlack( `*${e.data.identifier}* completed by ${e.actor?.name ?? "system"}\n${e.data.title}` ); }
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 32,172 | 21,302 | -34% | 1 | 1 | 0% | 5,577 | 5,937 | +6% | 0 | 0 | — |
case-02 | fail→fail | 24,799 | 23,231 | -6% | 1 | 1 | 0% | 4,114 | 6,480 | +58% | 0 | 0 | — |
case-03 | fail→fail | 17,775 | 15,388 | -13% | 1 | 1 | 0% | 2,577 | 4,556 | +77% | 0 | 0 | — |
case-04 | fail→pass | 20,461 | 19,144 | -6% | 1 | 1 | 0% | 2,799 | 5,124 | +83% | 0 | 0 | — |
case-05 | pass→pass | 15,316 | 14,308 | -7% | 1 | 1 | 0% | 2,015 | 4,267 | +112% | 0 | 0 | — |
case-06 | fail→pass | 17,473 | 9,500 | -46% | 1 | 1 | 0% | 2,176 | 3,265 | +50% | 0 | 0 | — |
case-07 | fail→fail | 24,272 | 18,935 | -22% | 1 | 1 | 0% | 2,896 | 5,206 | +80% | 0 | 0 | — |
case-08 | fail→pass | 16,464 | 13,955 | -15% | 1 | 1 | 0% | 1,762 | 4,079 | +131% | 0 | 0 | — |
case-09 | pass→pass | 25,747 | 16,841 | -35% | 1 | 1 | 0% | 3,220 | 5,069 | +57% | 0 | 0 | — |
case-15 | fail→pass | 21,769 | 2,620 | -88% | 1 | 1 | 0% | 2,885 | 2,861 | -1% | 0 | 0 | — |
case-10 | pass→pass | 19,169 | 13,580 | -29% | 1 | 1 | 0% | 2,036 | 3,727 | +83% | 0 | 0 | — |
case-11 | fail→pass | 20,338 | 8,390 | -59% | 1 | 1 | 0% | 2,361 | 4,218 | +79% | 0 | 0 | — |
case-12 | pass→pass | 12,655 | 10,808 | -15% | 1 | 1 | 0% | 2,278 | 4,155 | +82% | 0 | 0 | — |
case-13 | pass→pass | 19,990 | 8,164 | -59% | 1 | 1 | 0% | 2,228 | 4,000 | +80% | 0 | 0 | — |
case-14 | pass→pass | 18,344 | 19,285 | +5% | 1 | 1 | 0% | 2,478 | 4,418 | +78% | 0 | 0 | — |
case-16 | fail→pass | 15,579 | 2,618 | -83% | 1 | 1 | 0% | 1,885 | 2,955 | +57% | 0 | 0 | — |
case-17 | pass→pass | 18,665 | 14,456 | -23% | 1 | 1 | 0% | 2,142 | 4,262 | +99% | 0 | 0 | — |
case-18 | fail→pass | 12,208 | 5,778 | -53% | 1 | 1 | 0% | 1,266 | 3,336 | +164% | 0 | 0 | — |
case-19 | pass→pass | 21,535 | 18,854 | -12% | 1 | 1 | 0% | 3,090 | 5,054 | +64% | 0 | 0 | — |
case-20 | pass→pass | 22,685 | 26,347 | +16% | 1 | 1 | 0% | 3,292 | 6,070 | +84% | 0 | 0 | — |
case-21 | pass→pass | 22,812 | 17,011 | -25% | 1 | 1 | 0% | 2,701 | 4,790 | +77% | 0 | 0 | — |
case-22 | fail→pass | 8,342 | 6,171 | -26% | 1 | 1 | 0% | 1,580 | 3,446 | +118% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +41 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.