Claude-skill-registry linear-webhooks-events
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/linear-webhooks-events" ~/.claude/skills/majiayu000-claude-skill-registry-linear-webhooks-events && rm -rf "$T"
manifest:
skills/data/linear-webhooks-events/SKILL.mdsource content
Linear Webhooks & Events
Overview
Set up and handle Linear webhooks for real-time event notifications.
Prerequisites
- Linear workspace admin access
- Public endpoint for webhook delivery
- Webhook signing secret configured
Available Event Types
| Event Type | Description |
|---|---|
| Issue created, updated, or removed |
| Comment added or updated |
| Project changes |
| Cycle (sprint) changes |
| Label changes |
| Emoji reactions |
Instructions
Step 1: Create Webhook Endpoint
// api/webhooks/linear.ts (Vercel/Next.js style) import crypto from "crypto"; import type { NextApiRequest, NextApiResponse } from "next"; export const config = { api: { bodyParser: false, // Need raw body for signature }, }; async function getRawBody(req: NextApiRequest): Promise<string> { const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(chunk); } return Buffer.concat(chunks).toString("utf8"); } function verifySignature(payload: string, signature: string): boolean { const secret = process.env.LINEAR_WEBHOOK_SECRET!; const hmac = crypto.createHmac("sha256", secret); const expectedSignature = hmac.update(payload).digest("hex"); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } export default async function handler( req: NextApiRequest, res: NextApiResponse ) { if (req.method !== "POST") { return res.status(405).json({ error: "Method not allowed" }); } const rawBody = await getRawBody(req); const signature = req.headers["linear-signature"] as string; if (!signature || !verifySignature(rawBody, signature)) { return res.status(401).json({ error: "Invalid signature" }); } const event = JSON.parse(rawBody); // Process event await processLinearEvent(event); return res.status(200).json({ received: true }); }
Step 2: Event Processing Router
// lib/webhook-handlers.ts interface LinearWebhookPayload { action: "create" | "update" | "remove"; type: string; data: Record<string, unknown>; createdAt: string; organizationId: string; webhookTimestamp: number; webhookId: string; } type EventHandler = (data: Record<string, unknown>, action: string) => Promise<void>; const handlers: Record<string, EventHandler> = { Issue: handleIssueEvent, IssueComment: handleCommentEvent, Project: handleProjectEvent, Cycle: handleCycleEvent, }; export async function processLinearEvent(payload: LinearWebhookPayload) { const handler = handlers[payload.type]; if (!handler) { console.log(`No handler for event type: ${payload.type}`); return; } try { await handler(payload.data, payload.action); } catch (error) { console.error(`Error processing ${payload.type} event:`, error); throw error; } } async function handleIssueEvent(data: Record<string, unknown>, action: string) { const issue = data as { id: string; identifier: string; title: string; state: { name: string }; priority: number; team: { key: string }; }; console.log(`Issue ${action}: ${issue.identifier} - ${issue.title}`); switch (action) { case "create": await onIssueCreated(issue); break; case "update": await onIssueUpdated(issue); break; case "remove": await onIssueRemoved(issue.id); break; } } async function handleCommentEvent(data: Record<string, unknown>, action: string) { const comment = data as { id: string; body: string; issue: { identifier: string }; user: { name: string }; }; console.log(`Comment ${action} on ${comment.issue.identifier} by ${comment.user.name}`); } async function handleProjectEvent(data: Record<string, unknown>, action: string) { console.log(`Project ${action}:`, data); } async function handleCycleEvent(data: Record<string, unknown>, action: string) { console.log(`Cycle ${action}:`, data); }
Step 3: Business Logic Handlers
// lib/linear-handlers.ts import { sendSlackNotification } from "./slack"; import { syncToDatabase } from "./database"; async function onIssueCreated(issue: any) { // Sync to local database await syncToDatabase("issues", issue.id, issue); // Notify Slack for high-priority issues if (issue.priority <= 2) { await sendSlackNotification({ channel: "#engineering-alerts", text: `New high-priority issue: ${issue.identifier} - ${issue.title}`, }); } } async function onIssueUpdated(issue: any) { // Update local cache await syncToDatabase("issues", issue.id, issue); // Check for state changes if (issue.state?.name === "Done") { await celebrateCompletion(issue); } } async function onIssueRemoved(issueId: string) { await syncToDatabase("issues", issueId, null); // Soft delete } async function celebrateCompletion(issue: any) { console.log(`Issue completed: ${issue.identifier}`); }
Step 4: Register Webhook in Linear
# Using Linear UI: # 1. Go to Settings > API > Webhooks # 2. Click "Create webhook" # 3. Enter your endpoint URL # 4. Select events to receive # 5. Save and copy the signing secret
// Or via API import { LinearClient } from "@linear/sdk"; async function createWebhook() { const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY!, }); const result = await client.createWebhook({ url: "https://your-domain.com/api/webhooks/linear", label: "My Integration Webhook", teamId: "your-team-id", // Optional: limit to specific team resourceTypes: ["Issue", "IssueComment", "Project"], }); if (result.success) { const webhook = await result.webhook; console.log("Webhook created:", webhook?.id); console.log("Secret (save this!):", webhook?.secret); } }
Step 5: Local Development with ngrok
# Start your local server npm run dev # Runs on localhost:3000 # In another terminal, start ngrok ngrok http 3000 # Copy the https URL and add to Linear webhook settings # Example: https://abc123.ngrok.io/api/webhooks/linear
Step 6: Idempotent Event Processing
// lib/idempotency.ts import Redis from "ioredis"; const redis = new Redis(process.env.REDIS_URL); export async function processIdempotent( webhookId: string, processor: () => Promise<void> ): Promise<boolean> { const key = `webhook:${webhookId}`; // Check if already processed const exists = await redis.exists(key); if (exists) { console.log(`Webhook ${webhookId} already processed, skipping`); return false; } // Mark as processing await redis.setex(key, 86400, "processing"); // 24 hour TTL try { await processor(); await redis.setex(key, 86400, "completed"); return true; } catch (error) { await redis.del(key); // Allow retry throw error; } } // Usage in webhook handler await processIdempotent(payload.webhookId, async () => { await processLinearEvent(payload); });
Error Handling
| Error | Cause | Solution |
|---|---|---|
| Wrong secret or tampering | Verify webhook secret |
| Processing too slow | Use async queue |
| Webhook retry | Implement idempotency |
| Partial event | Handle gracefully |
Resources
Next Steps
Optimize performance with
linear-performance-tuning.