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 |
Issue created, updated, or removed |
IssueComment |
Comment added or updated |
Project |
Project changes |
Cycle |
Cycle (sprint) changes |
Label |
Label changes |
Reaction |
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 |
Invalid signature |
Wrong secret or tampering |
Verify webhook secret |
Timeout |
Processing too slow |
Use async queue |
Duplicate events |
Webhook retry |
Implement idempotency |
Missing data |
Partial event |
Handle gracefully |
Resources
Next Steps
Optimize performance with linear-performance-tuning.