When an issue status changes in Linear from "In Progress" to "Done," you need to know immediately—not when your polling script runs 5 minutes later. Linear webhooks solve this problem by sending real-time HTTP notifications to your server the moment events occur, enabling you to:
- Automatically notify stakeholders when high-priority issues are created or updated
- Trigger CI/CD pipelines when issues move to specific workflow states
- Sync Linear data with external tools like Slack, Jira, or your custom dashboard
- Generate analytics and reports based on real-time project activity
- Automate workflows like assigning issues, updating timelines, or sending alerts
Linear's GraphQL-based webhook system provides immediate event notifications for issues, comments, projects, cycles, and more. With proper signature verification using HMAC-SHA256, you can build secure, real-time integrations that respond instantly to your team's workflow changes.
In this comprehensive guide, you'll learn how to set up Linear webhooks, verify signatures securely, implement production-ready endpoints, and handle common integration scenarios. We'll also show you how to test webhooks using our Webhook Payload Generator without exposing your local development environment.
What Are Linear Webhooks?
Linear webhooks are HTTP callbacks that Linear's servers send to your specified endpoint URL whenever data changes in your workspace. Unlike traditional API polling where your application repeatedly asks "Has anything changed?", webhooks push notifications to you the moment events occur—typically within milliseconds.
How Linear Webhooks Work:
[Issue Updated] → [Linear Server] → [Your Webhook Endpoint] → [Your Application Logic]
Event Processes Receives Payload Triggers Actions
+ Signs Payload + Verifies Signature + Updates Database
+ Returns 200 OK + Sends Notifications
Key Benefits of Linear Webhooks
Real-Time Synchronization: Receive instant notifications when issues, comments, or projects change, eliminating polling delays and reducing API calls by 95%+ in typical applications.
GraphQL-Powered Payloads: Linear webhook payloads follow GraphQL entity structures, providing rich, nested data with all relevant relationships included in a single notification.
Granular Event Filtering: Subscribe to specific resource types (Issues, Comments, Projects, Cycles, Labels, etc.) and target individual teams or organization-wide events to receive only the data you need.
Secure by Default: Built-in HMAC-SHA256 signature verification, HTTPS-only endpoints, and timestamp validation prevent spoofing and replay attacks.
Prerequisites
Before setting up Linear webhooks, ensure you have:
- Admin Access: Workspace admin privileges or an OAuth app with
adminscope - HTTPS Endpoint: A publicly accessible HTTPS URL (localhost won't work)
- Development Tools: Ability to parse JSON and compute HMAC-SHA256 signatures
- Webhook Secret: Provided by Linear when you create a webhook (used for signature verification)
Linear webhooks are ideal for building real-time integrations, automating project workflows, syncing data across platforms, and creating custom dashboards that reflect your team's current work state instantly.
Setting Up Linear Webhooks
Linear provides two methods for creating webhooks: through the web interface or programmatically via the GraphQL API. Both methods require admin permissions and result in a webhook secret for signature verification.
Method 1: Create Webhooks via Linear Settings UI
Step 1: Navigate to API Settings
Log in to Linear and click on your workspace avatar in the bottom-left corner. Select Settings → API → Webhooks to access the webhook management interface.
Step 2: Create New Webhook
Click the "New Webhook" button. You'll see a form with several configuration options:
Step 3: Configure Webhook Details
Fill in the required fields:
- Label (Optional): A descriptive name for your webhook (e.g., "Slack Notifications" or "CI/CD Triggers")
- Webhook URL: Your HTTPS endpoint that will receive webhook POSTs (e.g.,
https://api.yourdomain.com/webhooks/linear) - Resource Types: Select which event types to monitor
Step 4: Select Resource Types
Choose from available resource types:
| Resource Type | Description |
|---|---|
| Issue | Issue created, updated, or removed |
| Comment | Comments added, edited, or deleted |
| Project | Project changes and updates |
| Cycle | Sprint/cycle modifications |
| IssueLabel | Label assignments and changes |
| Reaction | Emoji reactions to comments |
| IssueAttachment | File attachments on issues |
| ProjectUpdate | Project status update posts |
| Document | Document changes |
| Customer | Customer-related events |
| Issue SLA | SLA breach notifications |
Step 5: Configure Team Scope
Choose webhook scope:
- All Public Teams: Subscribe to events across your entire organization
- Specific Team: Target a single team's events using the team selector
Step 6: Save and Retrieve Secret
After saving, Linear displays your Webhook Secret (only shown once). Copy this immediately and store it securely in your environment variables:
LINEAR_WEBHOOK_SECRET=whs_1234567890abcdef1234567890abcdef
Method 2: Create Webhooks via GraphQL API
For programmatic webhook creation, use Linear's GraphQL API:
mutation CreateWebhook {
webhookCreate(
input: {
url: "https://api.yourdomain.com/webhooks/linear"
label: "Production Webhook"
resourceTypes: ["Issue", "Comment", "Project"]
teamId: "72b2a2dc-6f4f-4423-9d34-24b5bd10634a"
}
) {
success
webhook {
id
url
enabled
secret
resourceTypes
team {
id
name
}
}
}
}
Response:
{
"data": {
"webhookCreate": {
"success": true,
"webhook": {
"id": "f4b1c8e2-9d3a-4f1b-8e7c-6a5b4c3d2e1f",
"url": "https://api.yourdomain.com/webhooks/linear",
"enabled": true,
"secret": "whs_1234567890abcdef1234567890abcdef",
"resourceTypes": ["Issue", "Comment", "Project"],
"team": {
"id": "72b2a2dc-6f4f-4423-9d34-24b5bd10634a",
"name": "Engineering"
}
}
}
}
}
Pro Tips for Webhook Setup
Security Best Practices:
- Store webhook secrets in environment variables, never commit to version control
- Use separate webhooks for development, staging, and production environments
- Limit resource types to only what you need to reduce payload volume
URL Configuration:
- Ensure your endpoint returns 200 status within 5 seconds to avoid timeouts
- Use dedicated webhook routes (e.g.,
/webhooks/linearnot/api/general) - Implement health check endpoints for monitoring webhook delivery
Common Mistakes to Avoid:
- ❌ Using localhost URLs (Linear requires public HTTPS endpoints)
- ❌ Subscribing to all event types unnecessarily (increases processing overhead)
- ❌ Forgetting to copy the webhook secret (shown only once at creation)
- ❌ Not implementing signature verification (exposes endpoint to spoofing)
Rate Limits: Linear doesn't impose explicit rate limits on webhook deliveries, but failed endpoints that consistently timeout or return errors may be automatically disabled and require manual re-enabling.
Verifying Your Webhook Setup
After creating a webhook, Linear sends a test payload to verify your endpoint is reachable. Ensure your endpoint:
- Accepts POST requests with
Content-Type: application/json - Returns HTTP 200 status code
- Responds within 5 seconds
- Verifies the
Linear-Signatureheader (see next section)
You can also test your webhook configuration using our Webhook Payload Generator to create valid signed payloads without triggering actual Linear events.
Linear Webhook Events & Payloads
Linear sends webhook notifications for data changes across multiple resource types, each with a specific payload structure following Linear's GraphQL schema. Understanding these event types and their payloads is essential for building robust integrations.
Overview of Available Events
| Event Type | Actions | Common Use Cases |
|---|---|---|
| Issue | create, update, remove | Trigger CI builds, notify stakeholders, update external trackers |
| Comment | create, update, remove | Sync discussions to Slack, track feedback, analyze sentiment |
| Project | create, update, remove | Dashboard updates, milestone tracking, timeline management |
| Cycle | create, update, remove | Sprint planning automation, velocity tracking, burndown charts |
| IssueLabel | create, update, remove | Category analytics, automated tagging, workflow routing |
| Reaction | create, remove | Engagement tracking, sentiment analysis, team morale metrics |
| IssueAttachment | create, update, remove | File management, documentation syncing, backup automation |
| ProjectUpdate | create, update, remove | Status report distribution, stakeholder notifications |
| Document | create, update, remove | Knowledge base syncing, documentation versioning |
| Customer | create, update, remove | CRM integration, customer feedback tracking |
| Issue SLA | breaching, breached | Alert escalation, compliance monitoring, SLA reporting |
Event: Issue Created
Description: Triggered when a new issue is created in Linear, either manually by users or via API/integrations.
Payload Structure:
{
"action": "create",
"type": "Issue",
"createdAt": "2025-01-24T14:32:18.084Z",
"organizationId": "dc844923-f9a4-40a3-825c-dea7747e57d6",
"webhookTimestamp": 1706107938084,
"webhookId": "000042e3-d123-4980-b49f-8e140eef9329",
"url": "https://linear.app/company/issue/ENG-123",
"actor": {
"id": "b5ea5f1f-8adc-4f52-b4bd-ab4e84cf51ba",
"type": "user",
"name": "Sarah Chen",
"email": "[email protected]",
"url": "https://linear.app/company/profiles/sarah"
},
"data": {
"id": "8f7e6d5c-4b3a-2198-7f6e-5d4c3b2a1098",
"createdAt": "2025-01-24T14:32:18.076Z",
"updatedAt": "2025-01-24T14:32:18.076Z",
"number": 123,
"title": "Add user authentication to API endpoints",
"priority": 1,
"estimate": 5,
"sortOrder": 1234.56,
"startedAt": null,
"completedAt": null,
"canceledAt": null,
"autoClosedAt": null,
"triagedAt": null,
"teamId": "72b2a2dc-6f4f-4423-9d34-24b5bd10634a",
"cycleId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"projectId": "9e8d7c6b-5a4f-3e2d-1c0b-9a8f7e6d5c4b",
"stateId": "e3d2c1b0-a987-6f5e-4d3c-2b1a09876543",
"assigneeId": "f1e2d3c4-b5a6-9870-1234-567890fedcba",
"creatorId": "b5ea5f1f-8adc-4f52-b4bd-ab4e84cf51ba",
"labelIds": [
"l1a2b3c4-d5e6-f789-0123-456789abcdef"
],
"description": "Implement JWT-based authentication...",
"descriptionState": "stored",
"url": "https://linear.app/company/issue/ENG-123"
}
}
Key Fields:
action- Always "create" for new issuestype- Resource type (Issue)actor- User who created the issue with full profile detailsdata.id- Unique issue identifier (UUID)data.number- Human-readable issue number (e.g., ENG-123)data.title- Issue title/summarydata.priority- Priority level (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low)data.estimate- Story points or time estimatedata.teamId- Team identifier (use to fetch team details via API)data.stateId- Current workflow state (Backlog, Todo, In Progress, Done, etc.)data.assigneeId- Assigned user ID (null if unassigned)webhookTimestamp- UNIX timestamp in milliseconds when webhook was senturl- Direct link to view the issue in Linear
Event: Issue Updated
Description: Fired when any field of an existing issue changes, including status updates, assignments, priority changes, or description edits.
Payload Structure:
{
"action": "update",
"type": "Issue",
"createdAt": "2025-01-24T16:45:22.391Z",
"organizationId": "dc844923-f9a4-40a3-825c-dea7747e57d6",
"webhookTimestamp": 1706115922391,
"webhookId": "111153f4-e234-5091-c50g-9f251ffg0440",
"url": "https://linear.app/company/issue/ENG-123",
"actor": {
"id": "c6fb7e0g-9bde-5g63-bc5f-fg5e95dg62cb",
"type": "user",
"name": "Mike Johnson",
"email": "[email protected]",
"url": "https://linear.app/company/profiles/mike"
},
"data": {
"id": "8f7e6d5c-4b3a-2198-7f6e-5d4c3b2a1098",
"updatedAt": "2025-01-24T16:45:22.384Z",
"number": 123,
"title": "Add user authentication to API endpoints",
"priority": 1,
"estimate": 5,
"stateId": "f4e3d2c1-b0a9-8765-4321-0fed-cba98765",
"assigneeId": "c6fb7e0g-9bde-5g63-bc5f-fg5e95dg62cb",
"startedAt": "2025-01-24T16:45:22.384Z",
"completedAt": null
},
"updatedFrom": {
"stateId": "e3d2c1b0-a987-6f5e-4d3c-2b1a09876543",
"assigneeId": "f1e2d3c4-b5a6-9870-1234-567890fedcba",
"startedAt": null,
"updatedAt": "2025-01-24T14:32:18.076Z"
}
}
Key Fields:
action- Always "update" for modified issuesupdatedFrom- Object containing previous values of changed fields onlydata- Current state of the issue after the updateactor- User who made the change
Use Cases:
- Detect state transitions (Backlog → In Progress → Done) for analytics
- Notify assignees when they're assigned to issues
- Track priority escalations and send urgent alerts
- Monitor estimate changes for capacity planning
Event: Comment Created
Description: Triggered when a comment is added to an issue or document, including file attachments and @mentions.
Payload Structure:
{
"action": "create",
"type": "Comment",
"createdAt": "2025-01-24T17:20:45.156Z",
"organizationId": "dc844923-f9a4-40a3-825c-dea7747e57d6",
"webhookTimestamp": 1706118045156,
"webhookId": "222264g5-f345-6102-d61h-0g362ggh1551",
"url": "https://linear.app/company/issue/ENG-123#comment-abc123",
"actor": {
"id": "d7gc8f1h-0cef-6h74-cd6g-hg6f06eh73dc",
"type": "user",
"name": "Emma Davis",
"email": "[email protected]",
"url": "https://linear.app/company/profiles/emma"
},
"data": {
"id": "comment-abc123def456",
"createdAt": "2025-01-24T17:20:45.148Z",
"updatedAt": "2025-01-24T17:20:45.148Z",
"body": "I've started working on this. @mike can you review the auth flow design?",
"issueId": "8f7e6d5c-4b3a-2198-7f6e-5d4c3b2a1098",
"userId": "d7gc8f1h-0cef-6h74-cd6g-hg6f06eh73dc",
"url": "https://linear.app/company/issue/ENG-123#comment-abc123"
}
}
Key Fields:
data.body- Comment text (may include Markdown formatting and @mentions)data.issueId- Associated issue identifierdata.userId- Comment authorurl- Direct link to the comment
Event: Project Updated
Description: Sent when project details change, including name, description, status, target dates, or milestone progress.
Payload Structure:
{
"action": "update",
"type": "Project",
"createdAt": "2025-01-24T18:10:33.742Z",
"organizationId": "dc844923-f9a4-40a3-825c-dea7747e57d6",
"webhookTimestamp": 1706121033742,
"webhookId": "333375h6-g456-7213-e72i-1h473hhi2662",
"url": "https://linear.app/company/project/q1-2025-api-refresh",
"actor": {
"id": "e8hd9g2i-1dfg-7i85-de7h-ih7g17fi84ed",
"type": "user",
"name": "Alex Rivera",
"email": "[email protected]",
"url": "https://linear.app/company/profiles/alex"
},
"data": {
"id": "9e8d7c6b-5a4f-3e2d-1c0b-9a8f7e6d5c4b",
"createdAt": "2025-01-10T09:00:00.000Z",
"updatedAt": "2025-01-24T18:10:33.735Z",
"name": "Q1 2025 API Refresh",
"description": "Modernize authentication and improve performance",
"state": "started",
"progress": 0.42,
"targetDate": "2025-03-31",
"startDate": "2025-01-15",
"completedAt": null,
"canceledAt": null,
"url": "https://linear.app/company/project/q1-2025-api-refresh"
},
"updatedFrom": {
"progress": 0.35,
"updatedAt": "2025-01-23T12:00:00.000Z"
}
}
Key Fields:
data.state- Project state (planned, started, paused, completed, canceled)data.progress- Completion percentage (0.0 to 1.0)data.targetDate- Project deadline (ISO date)updatedFrom.progress- Previous completion percentage
Event: Cycle Updated
Description: Notifies when sprint/cycle details change, including issue assignments, progress updates, or cycle completion.
Payload Structure:
{
"action": "update",
"type": "Cycle",
"createdAt": "2025-01-24T19:00:15.892Z",
"organizationId": "dc844923-f9a4-40a3-825c-dea7747e57d6",
"webhookTimestamp": 1706124015892,
"webhookId": "444486i7-h567-8324-f83j-2i584iij3773",
"url": "https://linear.app/company/cycle/sprint-5",
"actor": {
"id": "f9ie0h3j-2egh-8j96-ef8i-ji8h28gj95fe",
"type": "user",
"name": "Jordan Taylor",
"email": "[email protected]",
"url": "https://linear.app/company/profiles/jordan"
},
"data": {
"id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"createdAt": "2025-01-15T00:00:00.000Z",
"updatedAt": "2025-01-24T19:00:15.885Z",
"number": 5,
"name": "Sprint 5",
"startsAt": "2025-01-22",
"endsAt": "2025-02-04",
"completedAt": null,
"progress": 0.68,
"scopeHistory": [45, 47, 48],
"completedIssueCountHistory": [12, 24, 32],
"completedScopeHistory": [18, 29, 32]
},
"updatedFrom": {
"progress": 0.62,
"completedIssueCountHistory": [12, 24, 29],
"updatedAt": "2025-01-23T19:00:00.000Z"
}
}
Key Fields:
data.number- Cycle number (sequential)data.startsAt/data.endsAt- Sprint date rangedata.progress- Completion percentage (0.0 to 1.0)data.scopeHistory- Array tracking total scope points over timedata.completedScopeHistory- Array tracking completed points for burndown charts
Common Webhook Patterns Across All Events:
- Consistent Structure: All webhooks include
action,type,actor,data,createdAt, andurlfields - Update Tracking: Update events always include
updatedFromwith previous values - GraphQL Alignment: Payload structures match Linear's GraphQL schema
- ID References: Related objects (teams, users, projects) provided as IDs, not nested objects
- Timestamps: All dates in ISO 8601 format; webhook delivery time in UNIX milliseconds
For complete field definitions and additional event types, consult the Linear GraphQL API Schema.
Webhook Signature Verification
Verifying webhook signatures is critical to ensure requests genuinely come from Linear and haven't been tampered with during transmission. Linear uses HMAC-SHA256 cryptographic signatures with hex encoding to sign every webhook payload.
Why Signature Verification Matters
Without verification, your endpoint is vulnerable to:
- Spoofing Attacks: Malicious actors sending fake webhook payloads to your endpoint
- Data Tampering: Man-in-the-middle attackers modifying payload contents
- Replay Attacks: Captured legitimate webhooks re-sent to trigger duplicate actions
- Unauthorized Access: Anyone discovering your webhook URL can send arbitrary data
With proper verification:
- ✅ Cryptographically confirm webhooks originate from Linear
- ✅ Detect any payload modifications during transit
- ✅ Prevent processing of forged or tampered requests
- ✅ Implement additional timestamp checks to block replay attacks
Linear's Signature Method
Algorithm: HMAC-SHA256
Signature Header: Linear-Signature
Encoding: Hexadecimal (lowercase)
Signature Input: Raw request body (unmodified bytes)
HTTP Headers Sent by Linear:
| Header | Description | Example Value |
|---|---|---|
Linear-Signature | HMAC-SHA256 hex digest | 766e1d90a96e2f5e... |
Linear-Delivery | Unique delivery UUID | 000042e3-d123-4980-b49f... |
Linear-Event | Resource type | Issue |
Content-Type | JSON content type | application/json; charset=utf-8 |
Step-by-Step Verification Process
1. Extract the Signature Header
Retrieve the Linear-Signature header from the incoming request.
2. Get the Webhook Secret
Use the secret Linear provided when you created the webhook (stored securely in environment variables).
3. Compute Expected Signature
Create an HMAC-SHA256 digest using:
- Key: Your webhook secret
- Message: The raw request body (important: must be unmodified bytes, not parsed/restringified JSON)
4. Compare Signatures
Use a timing-safe comparison function to prevent timing attacks. Standard string equality (=== or ==) is vulnerable because it returns immediately upon finding the first different character, potentially leaking information about the correct signature through timing analysis.
5. Validate Timestamp (Recommended)
Check that webhookTimestamp is within 60 seconds of current time to prevent replay attacks.
Implementation Examples
Node.js / Express
const crypto = require('crypto');
const express = require('express');
const app = express();
// CRITICAL: Use express.raw() to preserve the raw body for signature verification
// express.json() modifies the body, which will break signature verification
app.use('/webhooks/linear', express.raw({type: 'application/json'}));
app.post('/webhooks/linear', (req, res) => {
const signature = req.headers['linear-signature'];
const secret = process.env.LINEAR_WEBHOOK_SECRET;
if (!signature) {
console.error('Missing Linear-Signature header');
return res.status(401).send('Unauthorized');
}
// Compute expected signature from raw body
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(req.body) // req.body is Buffer with express.raw()
.digest('hex');
// Timing-safe comparison to prevent timing attacks
const signatureBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
console.error('Invalid signature');
return res.status(401).send('Unauthorized');
}
// Parse payload AFTER verification
const payload = JSON.parse(req.body.toString());
// Optional: Validate timestamp to prevent replay attacks
const now = Date.now();
const timeDiff = Math.abs(now - payload.webhookTimestamp);
const FIVE_MINUTES = 5 * 60 * 1000;
if (timeDiff > FIVE_MINUTES) {
console.error('Webhook timestamp too old (possible replay attack)');
return res.status(401).send('Webhook expired');
}
// Signature verified - process webhook
console.log(`Verified ${payload.type} ${payload.action} event`);
console.log(`Event ID: ${payload.webhookId}`);
console.log(`Actor: ${payload.actor.name} (${payload.actor.email})`);
// Return 200 immediately (process async)
res.status(200).send('Webhook received');
// Queue for background processing
processWebhookAsync(payload);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Linear webhook server listening on port ${PORT}`);
});
Python / Flask
import hmac
import hashlib
import time
from flask import Flask, request, jsonify
app = Flask(__name__)
LINEAR_WEBHOOK_SECRET = 'your_webhook_secret_here'
@app.route('/webhooks/linear', methods=['POST'])
def linear_webhook():
# Get signature from headers
signature = request.headers.get('Linear-Signature')
if not signature:
return jsonify({'error': 'Missing signature'}), 401
# Get raw body (bytes)
payload_bytes = request.get_data()
# Compute expected signature
expected_signature = hmac.new(
LINEAR_WEBHOOK_SECRET.encode('utf-8'),
payload_bytes,
hashlib.sha256
).hexdigest()
# Timing-safe comparison
if not hmac.compare_digest(signature, expected_signature):
return jsonify({'error': 'Invalid signature'}), 401
# Parse payload after verification
payload = request.get_json()
# Optional: Validate timestamp (prevent replay attacks)
webhook_timestamp = payload.get('webhookTimestamp', 0)
current_timestamp = int(time.time() * 1000) # Convert to milliseconds
time_diff = abs(current_timestamp - webhook_timestamp)
# Reject webhooks older than 5 minutes
if time_diff > 5 * 60 * 1000:
return jsonify({'error': 'Webhook expired'}), 401
# Signature verified - process webhook
print(f"Verified {payload['type']} {payload['action']} event")
print(f"Event ID: {payload['webhookId']}")
print(f"Actor: {payload['actor']['name']} ({payload['actor']['email']})")
# Return 200 immediately
return jsonify({'received': True}), 200
if __name__ == '__main__':
app.run(port=3000)
PHP
<?php
$secret = getenv('LINEAR_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_LINEAR_SIGNATURE'] ?? '';
if (empty($signature)) {
http_response_code(401);
die('Missing signature');
}
// Get raw POST body
$payload = file_get_contents('php://input');
// Compute expected signature
$expectedSignature = hash_hmac('sha256', $payload, $secret);
// Timing-safe comparison
if (!hash_equals($signature, $expectedSignature)) {
http_response_code(401);
die('Invalid signature');
}
// Parse payload after verification
$data = json_decode($payload, true);
// Optional: Validate timestamp (prevent replay attacks)
$webhookTimestamp = $data['webhookTimestamp'] ?? 0;
$currentTimestamp = intval(microtime(true) * 1000);
$timeDiff = abs($currentTimestamp - $webhookTimestamp);
// Reject webhooks older than 5 minutes
if ($timeDiff > 5 * 60 * 1000) {
http_response_code(401);
die('Webhook expired');
}
// Signature verified - process webhook
error_log("Verified {$data['type']} {$data['action']} event");
error_log("Event ID: {$data['webhookId']}");
error_log("Actor: {$data['actor']['name']} ({$data['actor']['email']})");
// Return 200 immediately
http_response_code(200);
echo 'Webhook received';
// Process webhook asynchronously
// processWebhookAsync($data);
?>
Using Linear's SDK for Signature Verification
Linear provides a TypeScript SDK with built-in signature verification:
import { LinearWebhookClient } from '@linear/sdk/webhooks';
const webhookClient = new LinearWebhookClient({
secret: process.env.LINEAR_WEBHOOK_SECRET
});
app.post('/webhooks/linear', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['linear-signature'];
const rawBody = req.body;
try {
// Verify and parse webhook payload
const payload = webhookClient.verify(rawBody, signature);
console.log(`Verified ${payload.type} ${payload.action} event`);
// Process based on event type
if (payload.type === 'Issue' && payload.action === 'create') {
handleIssueCreated(payload.data);
}
res.status(200).send('Webhook received');
} catch (error) {
console.error('Signature verification failed:', error);
res.status(401).send('Unauthorized');
}
});
Common Verification Errors
❌ Parsing JSON Before Verification
// WRONG - body modified, signature will fail
app.use(express.json());
app.post('/webhooks/linear', (req, res) => {
const signature = req.headers['linear-signature'];
const body = JSON.stringify(req.body); // Re-stringified, won't match
// Verification fails!
});
✅ Correct Approach
// CORRECT - raw body preserved
app.use('/webhooks/linear', express.raw({type: 'application/json'}));
app.post('/webhooks/linear', (req, res) => {
const signature = req.headers['linear-signature'];
// Verify with req.body (Buffer), parse after verification
});
❌ Using Wrong Secret
// WRONG - using API key instead of webhook secret
const secret = process.env.LINEAR_API_KEY; // ❌
// CORRECT - using webhook secret
const secret = process.env.LINEAR_WEBHOOK_SECRET; // ✅
❌ Not Using Timing-Safe Comparison
// WRONG - vulnerable to timing attacks
if (signature === expectedSignature) { // ❌
// CORRECT - timing-safe comparison
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { // ✅
❌ Forgetting to Validate Timestamp
// WRONG - accepts old webhooks (replay attack vulnerability)
// No timestamp validation
// CORRECT - reject webhooks older than 5 minutes
const timeDiff = Math.abs(Date.now() - payload.webhookTimestamp);
if (timeDiff > 5 * 60 * 1000) {
return res.status(401).send('Webhook expired');
}
Security Checklist
- ✅ Verify
Linear-Signatureon every webhook request - ✅ Use raw request body (not parsed/restringified JSON)
- ✅ Store webhook secret in environment variables (never commit)
- ✅ Use timing-safe comparison functions
- ✅ Validate
webhookTimestampis recent (< 60 seconds recommended) - ✅ Return appropriate HTTP status codes (200 for success, 401 for auth failures)
- ✅ Log verification failures for security monitoring
- ✅ Implement IP whitelisting for Linear's webhook IPs (optional but recommended)
Linear's Webhook IP Addresses:
- 35.231.147.226
- 35.243.134.228
- 34.140.253.14
- 34.38.87.206
- 34.134.222.122
- 35.222.25.142
By implementing proper signature verification, you ensure that only authentic Linear webhooks are processed by your application, protecting against spoofing, tampering, and replay attacks.
Testing Linear Webhooks
Testing webhooks during development presents unique challenges: Linear's servers can't reach your localhost, you need valid webhook signatures, and triggering real Linear events for every test isn't practical. Here are proven solutions for testing Linear webhooks effectively.
Challenge: Linear Can't Reach Localhost
Linear requires publicly accessible HTTPS endpoints and won't send webhooks to:
http://localhost:3000http://127.0.0.1:3000http://192.168.x.x(local network IPs)- Non-HTTPS URLs
Solution 1: Expose Localhost with ngrok
ngrok creates a secure tunnel from a public URL to your local development server, allowing Linear to deliver webhooks to your localhost.
Step 1: Install ngrok
# macOS (Homebrew)
brew install ngrok
# Windows (Chocolatey)
choco install ngrok
# Linux / Manual Installation
# Download from https://ngrok.com/download
Step 2: Start Your Local Webhook Server
# Start your webhook endpoint on port 3000
node server.js
# Server listening on http://localhost:3000
Step 3: Create ngrok Tunnel
# Expose port 3000 via ngrok
ngrok http 3000
ngrok Output:
Session Status online
Account [email protected] (Plan: Free)
Version 3.5.0
Region United States (us)
Forwarding https://abc123def456.ngrok-free.app -> http://localhost:3000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Step 4: Configure Linear Webhook
Use the ngrok HTTPS URL in your Linear webhook settings:
https://abc123def456.ngrok-free.app/webhooks/linear
Step 5: Trigger Linear Events
Create or update issues in Linear and watch your local console for webhook deliveries.
ngrok Pro Tips:
- Custom Subdomain (paid plans):
ngrok http 3000 --subdomain=myapp-webhooks→https://myapp-webhooks.ngrok.io - Replay Requests: Visit
http://127.0.0.1:4040for ngrok's web interface to inspect and replay webhook requests - Persistent URLs (paid plans): Reserve fixed URLs that don't change between sessions
- Authentication: Add basic auth to protect your endpoint:
ngrok http 3000 --auth="username:password"
ngrok Limitations:
- Free tier URLs change on every restart (reconfigure webhook in Linear each time)
- Session timeout after 8 hours (free tier)
- Requires ngrok process running while testing
Solution 2: Webhook Payload Generator Tool
For testing without exposing your localhost or triggering real Linear events, use our Webhook Payload Generator to create test payloads with valid signatures.
Benefits:
- ✅ No tunneling or public URLs required
- ✅ Test signature verification logic offline
- ✅ Customize payload values for edge cases
- ✅ Test error handling without affecting production data
- ✅ Generate multiple event types instantly
- ✅ No rate limits or Linear account required
How to Use:
Step 1: Visit the Webhook Payload Generator
Navigate to /tools/webhook-payload-generator
Step 2: Select Linear as Provider
Choose "Linear" from the provider dropdown menu.
Step 3: Choose Event Type
Select the event type you want to test:
- Issue Created
- Issue Updated
- Comment Created
- Project Updated
- Cycle Updated
Step 4: Customize Payload Fields
Modify field values to match your test scenarios:
- Issue titles, priorities, and assignees
- Timestamps for testing replay attack protection
- Actor details for testing access control
- Data values for edge cases (null fields, special characters, etc.)
Step 5: Enter Your Webhook Secret
Paste your LINEAR_WEBHOOK_SECRET to generate a valid signature.
Step 6: Generate Signed Payload
Click "Generate Payload" to create a JSON payload with a valid Linear-Signature header.
Step 7: Send to Your Endpoint
Copy the payload and signature, then send to your local endpoint:
curl -X POST http://localhost:3000/webhooks/linear \
-H "Content-Type: application/json" \
-H "Linear-Signature: 766e1d90a96e2f5ecec342a99c5552999dd95d49250171b902d703fd674f5086" \
-H "Linear-Event: Issue" \
-H "Linear-Delivery: 000042e3-d123-4980-b49f-8e140eef9329" \
-d '{"action":"create","type":"Issue","data":{...}}'
Test Scenarios to Verify:
# Test 1: Valid signature (should succeed)
# Generate payload with correct secret, verify signature passes
# Test 2: Invalid signature (should fail with 401)
# Modify signature header to incorrect value
# Test 3: Modified payload (should fail with 401)
# Change payload content without updating signature
# Test 4: Old timestamp (should fail if you validate timestamps)
# Set webhookTimestamp to 10 minutes ago
# Test 5: Missing signature header (should fail with 401)
# Omit Linear-Signature header entirely
Solution 3: Linear's Webhook Delivery Logs
Linear provides webhook delivery logs in your organization settings:
Access Logs:
- Navigate to Settings → API → Webhooks
- Click on your webhook
- View Recent Deliveries tab
Available Information:
- Delivery timestamp
- HTTP status code returned by your endpoint
- Response time
- Request headers sent
- Payload sent (JSON)
- Error messages (if delivery failed)
Use Logs To:
- Verify Linear is sending webhooks to your endpoint
- Debug delivery failures (timeouts, SSL errors, connection refused)
- Inspect actual payload structures for each event type
- Confirm webhook is enabled and not auto-disabled
- Check retry attempts and timing
Testing Checklist
Pre-Deployment Testing:
- Signature verification passes with valid signature
- Signature verification fails with invalid signature
- Signature verification fails with modified payload
- Endpoint returns 200 status within 5 seconds
- Endpoint handles missing
Linear-Signatureheader - Endpoint handles missing/invalid webhook secret
- Timestamp validation rejects old webhooks (if implemented)
- Idempotency check prevents duplicate processing
- Error handling for malformed JSON payloads
- Error handling for unexpected event types
- Async processing doesn't block 200 response
- Logs capture all webhook events for debugging
Production Readiness:
- HTTPS certificate is valid and not self-signed
- Endpoint is publicly accessible (not behind firewall)
- Webhook secret stored in environment variables
- IP whitelisting configured for Linear's IPs (optional)
- Monitoring alerts for signature verification failures
- Monitoring alerts for webhook processing errors
- Database tracks processed event IDs (idempotency)
- Queue system handles async processing
- Backup mechanism for missed webhooks (reconciliation job)
Testing Best Practices
1. Test Locally First
Use the Webhook Payload Generator to test your signature verification and payload parsing logic before exposing your endpoint publicly.
2. Test with ngrok in Staging
Use ngrok to test end-to-end webhook delivery from Linear to your development environment before deploying to production.
3. Monitor Linear's Delivery Logs
Regularly check Linear's webhook delivery logs to catch failures early and understand actual webhook behavior.
4. Implement Health Checks
Create a separate endpoint that Linear can use to verify your webhook endpoint is healthy:
app.get('/webhooks/linear/health', (req, res) => {
res.status(200).json({ status: 'healthy', timestamp: Date.now() });
});
5. Use Separate Webhooks for Environments
Create different webhooks in Linear for development, staging, and production with unique secrets and URLs.
By combining ngrok for end-to-end testing, the Webhook Payload Generator for signature verification testing, and Linear's delivery logs for production monitoring, you can thoroughly test your Linear webhook integration at every stage of development.
Implementation Example
Building a production-ready Linear webhook endpoint requires more than just signature verification. You need to respond quickly, process asynchronously, handle duplicates, and gracefully manage errors. Here's a complete implementation with best practices.
Requirements for Production Webhooks
Performance Requirements:
- Respond with HTTP 200 within 5 seconds (Linear's timeout)
- Process webhook logic asynchronously (don't block response)
- Handle concurrent webhook deliveries safely
Reliability Requirements:
- Implement idempotency (prevent duplicate processing)
- Track event IDs to detect duplicates
- Handle retries gracefully (same event sent multiple times)
- Implement reconciliation for missed webhooks
Security Requirements:
- Verify HMAC-SHA256 signatures on every request
- Validate webhook timestamps (prevent replay attacks)
- Return appropriate error codes (401 for auth failures)
- Log security events (signature failures, old timestamps)
Complete Node.js Implementation
This example uses Express, Bull (Redis-based queue), and PostgreSQL for event tracking:
const express = require('express');
const crypto = require('crypto');
const Queue = require('bull');
const { Pool } = require('pg');
const app = express();
// PostgreSQL connection for event tracking (idempotency)
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
// Redis-based queue for async webhook processing
const webhookQueue = new Queue('linear-webhooks', {
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379
}
});
// Parse raw body for signature verification
app.use('/webhooks/linear', express.raw({type: 'application/json'}));
// Linear webhook endpoint
app.post('/webhooks/linear', async (req, res) => {
const startTime = Date.now();
try {
// 1. Extract headers
const signature = req.headers['linear-signature'];
const linearEvent = req.headers['linear-event'];
const deliveryId = req.headers['linear-delivery'];
if (!signature) {
console.error('Missing Linear-Signature header');
return res.status(401).json({ error: 'Missing signature' });
}
// 2. Verify signature
const secret = process.env.LINEAR_WEBHOOK_SECRET;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex');
const signatureBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
console.error('Invalid signature', {
deliveryId,
linearEvent,
received: signature.substring(0, 16) + '...',
expected: expectedSignature.substring(0, 16) + '...'
});
return res.status(401).json({ error: 'Invalid signature' });
}
// 3. Parse payload after verification
const payload = JSON.parse(req.body.toString());
// 4. Validate timestamp (prevent replay attacks)
const now = Date.now();
const timeDiff = Math.abs(now - payload.webhookTimestamp);
const FIVE_MINUTES = 5 * 60 * 1000;
if (timeDiff > FIVE_MINUTES) {
console.error('Webhook timestamp too old', {
webhookId: payload.webhookId,
webhookTimestamp: payload.webhookTimestamp,
currentTime: now,
ageMinutes: (timeDiff / 1000 / 60).toFixed(2)
});
return res.status(401).json({ error: 'Webhook expired' });
}
// 5. Check for duplicate (idempotency)
const eventId = payload.webhookId;
const existingEvent = await checkIfProcessed(eventId);
if (existingEvent) {
console.log('Duplicate webhook received', {
webhookId: eventId,
type: payload.type,
action: payload.action,
previouslyProcessedAt: existingEvent.processed_at
});
return res.status(200).json({ received: true, duplicate: true });
}
// 6. Record webhook receipt (idempotency tracking)
await recordWebhookReceipt(payload);
// 7. Queue for async processing
await webhookQueue.add('process-webhook', {
webhookId: eventId,
deliveryId,
eventType: payload.type,
action: payload.action,
payload
}, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
},
removeOnComplete: true
});
// 8. Return 200 immediately
const processingTime = Date.now() - startTime;
console.log('Webhook queued successfully', {
webhookId: eventId,
type: payload.type,
action: payload.action,
actor: payload.actor.email,
processingTimeMs: processingTime
});
res.status(200).json({
received: true,
webhookId: eventId,
processingTimeMs
});
} catch (error) {
console.error('Webhook processing error:', error);
// Return 200 to prevent retries for our errors
const processingTime = Date.now() - startTime;
res.status(200).json({
received: true,
error: true,
processingTimeMs
});
}
});
// Process webhooks from queue
webhookQueue.process('process-webhook', async (job) => {
const { webhookId, eventType, action, payload } = job.data;
try {
console.log('Processing webhook', {
webhookId,
eventType,
action
});
// Update status to processing
await markAsProcessing(webhookId);
// Handle different event types
switch (eventType) {
case 'Issue':
await handleIssueEvent(payload);
break;
case 'Comment':
await handleCommentEvent(payload);
break;
case 'Project':
await handleProjectEvent(payload);
break;
case 'Cycle':
await handleCycleEvent(payload);
break;
default:
console.warn('Unknown event type', { eventType, webhookId });
}
// Mark as completed
await markAsCompleted(webhookId);
console.log('Webhook processed successfully', {
webhookId,
eventType,
action
});
} catch (error) {
console.error('Failed to process webhook', {
webhookId,
eventType,
error: error.message,
stack: error.stack
});
await markAsFailed(webhookId, error.message);
throw error; // Will trigger queue retry
}
});
// Business logic handlers
async function handleIssueEvent(payload) {
const { action, data } = payload;
switch (action) {
case 'create':
console.log('New issue created', {
issueId: data.id,
number: data.number,
title: data.title,
priority: data.priority,
assignee: data.assigneeId
});
// Example: Send Slack notification for high-priority issues
if (data.priority === 1) { // Urgent
await sendSlackNotification({
channel: '#urgent-issues',
text: `🚨 Urgent Issue Created: ${data.title}`,
url: payload.url,
assignee: payload.actor.name
});
}
// Example: Update external system
await syncIssueToExternalSystem(data);
break;
case 'update':
console.log('Issue updated', {
issueId: data.id,
number: data.number,
changedFields: Object.keys(payload.updatedFrom || {})
});
// Example: Detect state changes (e.g., moved to "Done")
if (payload.updatedFrom?.stateId) {
const oldState = await getStateName(payload.updatedFrom.stateId);
const newState = await getStateName(data.stateId);
console.log('Issue state changed', {
issueId: data.id,
from: oldState,
to: newState
});
// Example: Trigger CI/CD when issue moves to "In Progress"
if (newState === 'In Progress') {
await triggerCIPipeline(data);
}
// Example: Send completion notification when moved to "Done"
if (newState === 'Done') {
await notifyStakeholders(data);
}
}
break;
case 'remove':
console.log('Issue deleted', {
issueId: data.id
});
await removeIssueFromExternalSystem(data.id);
break;
}
}
async function handleCommentEvent(payload) {
const { action, data } = payload;
if (action === 'create') {
console.log('New comment added', {
commentId: data.id,
issueId: data.issueId,
author: payload.actor.name,
bodyPreview: data.body.substring(0, 100)
});
// Example: Sync comments to Slack thread
await syncCommentToSlack(data, payload.url);
// Example: Detect @mentions and notify users
const mentions = extractMentions(data.body);
if (mentions.length > 0) {
await notifyMentionedUsers(mentions, data, payload.url);
}
}
}
async function handleProjectEvent(payload) {
const { action, data } = payload;
if (action === 'update' && payload.updatedFrom?.progress) {
console.log('Project progress updated', {
projectId: data.id,
name: data.name,
oldProgress: (payload.updatedFrom.progress * 100).toFixed(1) + '%',
newProgress: (data.progress * 100).toFixed(1) + '%'
});
// Example: Send weekly project digest
if (isEndOfWeek() && data.progress > 0.75) {
await sendProjectStatusReport(data);
}
}
}
async function handleCycleEvent(payload) {
const { action, data } = payload;
if (action === 'update') {
console.log('Cycle updated', {
cycleId: data.id,
name: data.name,
progress: (data.progress * 100).toFixed(1) + '%',
daysRemaining: getDaysRemaining(data.endsAt)
});
// Example: Alert if cycle is behind schedule
const expectedProgress = calculateExpectedProgress(data.startsAt, data.endsAt);
if (data.progress < expectedProgress - 0.15) { // 15% behind
await sendCycleAlert(data, 'behind_schedule');
}
}
}
// Helper functions for database operations
async function checkIfProcessed(webhookId) {
const result = await pool.query(
'SELECT * FROM webhook_events WHERE webhook_id = $1',
[webhookId]
);
return result.rows[0] || null;
}
async function recordWebhookReceipt(payload) {
await pool.query(
`INSERT INTO webhook_events
(webhook_id, event_type, action, actor_id, status, received_at, payload)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
payload.webhookId,
payload.type,
payload.action,
payload.actor.id,
'received',
new Date(),
JSON.stringify(payload)
]
);
}
async function markAsProcessing(webhookId) {
await pool.query(
'UPDATE webhook_events SET status = $1, processing_started_at = $2 WHERE webhook_id = $3',
['processing', new Date(), webhookId]
);
}
async function markAsCompleted(webhookId) {
await pool.query(
'UPDATE webhook_events SET status = $1, completed_at = $2 WHERE webhook_id = $3',
['completed', new Date(), webhookId]
);
}
async function markAsFailed(webhookId, errorMessage) {
await pool.query(
'UPDATE webhook_events SET status = $1, error = $2, failed_at = $3 WHERE webhook_id = $4',
['failed', errorMessage, new Date(), webhookId]
);
}
// Placeholder functions for external integrations
async function sendSlackNotification({ channel, text, url, assignee }) {
// Implement Slack API call
console.log(`Slack: ${channel} - ${text}`);
}
async function syncIssueToExternalSystem(issueData) {
// Implement external system sync (Jira, etc.)
console.log('Syncing issue to external system', issueData.id);
}
async function triggerCIPipeline(issueData) {
// Trigger CI/CD pipeline (GitHub Actions, Jenkins, etc.)
console.log('Triggering CI pipeline for issue', issueData.number);
}
async function notifyStakeholders(issueData) {
// Send email/notification to stakeholders
console.log('Notifying stakeholders about completed issue', issueData.number);
}
async function getStateName(stateId) {
// Fetch state name from Linear API or local cache
return 'In Progress'; // Placeholder
}
async function extractMentions(commentBody) {
// Parse @mentions from comment body
const mentionRegex = /@\[([^\]]+)\]\(([^)]+)\)/g;
const mentions = [];
let match;
while ((match = mentionRegex.exec(commentBody)) !== null) {
mentions.push({ name: match[1], url: match[2] });
}
return mentions;
}
function getDaysRemaining(endDate) {
const end = new Date(endDate);
const now = new Date();
return Math.ceil((end - now) / (1000 * 60 * 60 * 24));
}
function calculateExpectedProgress(startDate, endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
const now = new Date();
const total = end - start;
const elapsed = now - start;
return Math.max(0, Math.min(1, elapsed / total));
}
function isEndOfWeek() {
return new Date().getDay() === 5; // Friday
}
// Health check endpoint
app.get('/webhooks/linear/health', (req, res) => {
res.status(200).json({
status: 'healthy',
timestamp: Date.now(),
queueStatus: {
waiting: webhookQueue.getWaitingCount(),
active: webhookQueue.getActiveCount(),
completed: webhookQueue.getCompletedCount(),
failed: webhookQueue.getFailedCount()
}
});
});
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Linear webhook server listening on port ${PORT}`);
console.log(`Health check: http://localhost:${PORT}/webhooks/linear/health`);
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received, closing server gracefully');
await webhookQueue.close();
await pool.end();
process.exit(0);
});
Database Schema for Event Tracking
CREATE TABLE webhook_events (
id SERIAL PRIMARY KEY,
webhook_id VARCHAR(255) UNIQUE NOT NULL,
event_type VARCHAR(50) NOT NULL,
action VARCHAR(20) NOT NULL,
actor_id VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'received',
received_at TIMESTAMP NOT NULL,
processing_started_at TIMESTAMP,
completed_at TIMESTAMP,
failed_at TIMESTAMP,
error TEXT,
payload JSONB,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_webhook_id (webhook_id),
INDEX idx_status (status),
INDEX idx_event_type_action (event_type, action),
INDEX idx_received_at (received_at)
);
Key Implementation Features
1. Raw Body Parsing: Uses express.raw() to preserve body for signature verification
2. Timing-Safe Comparison: Uses crypto.timingSafeEqual() to prevent timing attacks
3. Timestamp Validation: Rejects webhooks older than 5 minutes (configurable)
4. Idempotency: Tracks webhookId in database to prevent duplicate processing
5. Queue-Based Processing: Returns 200 immediately, processes async via Bull queue
6. Error Handling: Graceful failures still return 200 to prevent unnecessary retries
7. Detailed Logging: Comprehensive logs for debugging and monitoring
8. Health Check: Endpoint for monitoring webhook processing status
9. Graceful Shutdown: Closes queue and database connections cleanly
10. Retry Logic: Bull queue retries failed jobs 3 times with exponential backoff
This implementation handles production requirements while remaining maintainable and testable. Customize the business logic handlers (handleIssueEvent, etc.) for your specific use cases.
Best Practices
Building reliable Linear webhook integrations requires attention to security, performance, and operational concerns. Follow these best practices to create robust, production-ready webhook endpoints.
Security Best Practices
Always Verify Signatures
// ✅ CORRECT: Verify every webhook
const expectedSig = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
return res.status(401).send('Unauthorized');
}
// ❌ WRONG: Skipping verification in development
if (process.env.NODE_ENV === 'production') {
// Only verify in production - DANGEROUS!
}
Use HTTPS Endpoints Only
Linear requires HTTPS. Never use HTTP or accept webhooks without SSL:
// ✅ CORRECT: HTTPS endpoint
https://api.yourdomain.com/webhooks/linear
// ❌ WRONG: HTTP endpoint (Linear will reject)
http://api.yourdomain.com/webhooks/linear
Store Secrets in Environment Variables
// ✅ CORRECT: Environment variables
const secret = process.env.LINEAR_WEBHOOK_SECRET;
// ❌ WRONG: Hardcoded secrets
const secret = 'whs_1234567890abcdef'; // NEVER DO THIS
Validate Timestamps to Prevent Replay Attacks
// ✅ CORRECT: Check webhook age
const timeDiff = Math.abs(Date.now() - payload.webhookTimestamp);
const FIVE_MINUTES = 5 * 60 * 1000;
if (timeDiff > FIVE_MINUTES) {
return res.status(401).send('Webhook expired');
}
Implement IP Whitelisting (Optional)
Linear sends webhooks from specific IP addresses. Add firewall rules:
35.231.147.226
35.243.134.228
34.140.253.14
34.38.87.206
34.134.222.122
35.222.25.142
Rate Limit Webhook Endpoints
Protect against abuse even with signature verification:
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many webhook requests'
});
app.use('/webhooks/linear', webhookLimiter);
Performance Best Practices
Respond Within 5 Seconds
Linear times out after 5 seconds. Return 200 immediately, process async:
// ✅ CORRECT: Return 200, process async
app.post('/webhooks/linear', async (req, res) => {
// Verify signature
// Queue for processing
res.status(200).send('Received'); // Return immediately
// Processing happens in background queue
});
// ❌ WRONG: Blocking response
app.post('/webhooks/linear', async (req, res) => {
// Verify signature
await processWebhook(payload); // Takes 30 seconds - TIMEOUT!
res.status(200).send('Done');
});
Use Queue Systems for Async Processing
Offload processing to background workers:
// ✅ CORRECT: Queue-based processing (Bull, BullMQ, SQS)
await webhookQueue.add('process', payload);
res.status(200).send('Queued');
// Process in background worker
webhookQueue.process('process', async (job) => {
await handleWebhook(job.data);
});
Implement Exponential Backoff for External Calls
When calling external APIs, retry with increasing delays:
async function sendSlackNotification(data, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
await slackAPI.send(data);
return;
} catch (error) {
if (attempt === retries) throw error;
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
console.log(`Slack API failed, retrying in ${delay}ms`);
await sleep(delay);
}
}
}
Monitor Webhook Processing Times
Track performance metrics to identify bottlenecks:
const startTime = Date.now();
// Process webhook
const duration = Date.now() - startTime;
console.log('Webhook processed', {
webhookId: payload.webhookId,
durationMs: duration
});
// Alert if processing takes too long
if (duration > 2000) {
alertOps('Slow webhook processing', { webhookId, duration });
}
Reliability Best Practices
Implement Idempotency
Track webhookId to prevent duplicate processing:
// ✅ CORRECT: Check if already processed
const exists = await db.webhookEvents.findOne({ webhookId });
if (exists) {
return res.status(200).json({ received: true, duplicate: true });
}
// Process webhook
await processWebhook(payload);
// Record as processed
await db.webhookEvents.create({ webhookId, processedAt: new Date() });
Handle Duplicate Webhooks Gracefully
Linear may send the same webhook multiple times (retries). Use idempotent operations:
// ✅ CORRECT: Idempotent update
await db.issues.upsert({
where: { linearId: data.id },
update: { status: data.state, updatedAt: new Date() },
create: { linearId: data.id, status: data.state, createdAt: new Date() }
});
// ❌ WRONG: Non-idempotent operation
await db.issues.create({ linearId: data.id }); // Will fail on duplicate
Don't Rely Solely on Webhooks
Implement periodic reconciliation to catch missed webhooks:
// Run daily reconciliation job
cron.schedule('0 2 * * *', async () => {
console.log('Starting daily reconciliation');
// Fetch recent Linear issues
const recentIssues = await linearAPI.issues({
filter: { updatedAt: { gt: getDaysAgo(7) } }
});
// Compare with local database
for (const issue of recentIssues.nodes) {
const local = await db.issues.findOne({ linearId: issue.id });
if (!local || local.updatedAt < new Date(issue.updatedAt)) {
console.log('Reconciling issue', issue.identifier);
await syncIssue(issue);
}
}
});
Implement Retry Logic for Failed Processing
Use queue retry mechanisms for transient failures:
webhookQueue.add('process', payload, {
attempts: 3, // Retry up to 3 times
backoff: {
type: 'exponential',
delay: 2000 // Start with 2s delay, double each attempt
}
});
webhookQueue.process('process', async (job) => {
try {
await processWebhook(job.data);
} catch (error) {
console.error(`Processing failed (attempt ${job.attemptsMade}/${job.opts.attempts})`, error);
throw error; // Trigger retry
}
});
Log All Webhook Events
Comprehensive logging aids debugging and auditing:
console.log('Webhook received', {
webhookId: payload.webhookId,
eventType: payload.type,
action: payload.action,
actor: payload.actor.email,
timestamp: payload.webhookTimestamp,
receivedAt: new Date().toISOString()
});
Monitoring Best Practices
Track Webhook Delivery Success Rate
Monitor how many webhooks Linear successfully delivers vs. failures:
metrics.increment('linear.webhook.received', {
event_type: payload.type,
action: payload.action
});
// In Linear's webhook logs, track failure rates
Alert on Signature Verification Failures
Repeated signature failures may indicate an attack or misconfiguration:
if (!signatureValid) {
metrics.increment('linear.webhook.signature_failure');
// Alert if more than 5 failures in 5 minutes
const recentFailures = await getRecentSignatureFailures(5);
if (recentFailures > 5) {
alertSecurity('Multiple signature verification failures', {
count: recentFailures,
lastFailure: new Date()
});
}
return res.status(401).send('Unauthorized');
}
Monitor Processing Queue Depth
Alert if queue backlog grows too large:
setInterval(async () => {
const waiting = await webhookQueue.getWaitingCount();
const active = await webhookQueue.getActiveCount();
metrics.gauge('linear.webhook.queue.waiting', waiting);
metrics.gauge('linear.webhook.queue.active', active);
if (waiting > 1000) {
alertOps('Webhook queue backlog critical', { waiting, active });
}
}, 60000); // Check every minute
Log Event IDs for Traceability
Always include webhookId and deliveryId in logs:
console.log('Processing webhook', {
webhookId: payload.webhookId,
deliveryId: req.headers['linear-delivery'],
eventType: payload.type,
action: payload.action,
correlationId: generateCorrelationId()
});
Set Up Health Checks
Expose health endpoint for monitoring services:
app.get('/webhooks/linear/health', async (req, res) => {
try {
// Check database connectivity
await pool.query('SELECT 1');
// Check queue status
const queueHealth = await webhookQueue.getJobCounts();
res.status(200).json({
status: 'healthy',
timestamp: Date.now(),
database: 'connected',
queue: queueHealth
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message
});
}
});
Linear-Specific Best Practices
Subscribe Only to Needed Event Types
Reduce processing overhead by filtering events at the source:
// ✅ CORRECT: Specific events only
resourceTypes: ["Issue", "Comment"]
// ❌ INEFFICIENT: All events (if you only need Issues)
// You'll receive and process many unnecessary webhooks
Handle Linear's Retry Behavior
Linear retries failed webhooks 3 times (1min, 1hr, 6hrs). Ensure idempotency:
// Webhook receives the same event 3 times due to transient failure
// First attempt: Process (database timeout)
// Second attempt: Skip (already processed, idempotency check)
// Third attempt: Skip (already processed, idempotency check)
Event Ordering Not Guaranteed
Process events independently; don't assume chronological order:
// ❌ WRONG: Assuming order
// Webhook 1: Issue created (arrives second)
// Webhook 2: Issue updated (arrives first)
// Processing webhook 2 fails because issue doesn't exist yet
// ✅ CORRECT: Handle out-of-order events
async function handleIssueUpdate(payload) {
let issue = await db.issues.findOne({ linearId: payload.data.id });
if (!issue) {
// Issue doesn't exist yet, create it
console.log('Creating issue from update event (out of order)');
issue = await db.issues.create({
linearId: payload.data.id,
...payload.data
});
} else {
// Update existing issue
await db.issues.update({ linearId: payload.data.id }, payload.data);
}
}
Use Linear's SDK When Available
The TypeScript SDK provides type safety and helper methods:
import { LinearWebhookClient } from '@linear/sdk/webhooks';
const webhookClient = new LinearWebhookClient({
secret: process.env.LINEAR_WEBHOOK_SECRET
});
// Automatic signature verification and parsing
const payload = webhookClient.verify(rawBody, signature);
Disable Webhooks During Maintenance
When performing database migrations or major updates, temporarily disable webhooks in Linear's settings to avoid processing errors.
By following these best practices, you'll build a secure, performant, and reliable Linear webhook integration that handles production traffic gracefully and recovers from failures automatically.
Common Issues & Troubleshooting
Even with careful implementation, webhook integrations can encounter issues. Here are the most common problems with Linear webhooks, their causes, and step-by-step solutions.
Issue 1: Signature Verification Failing
Symptoms:
- Webhooks consistently rejected with 401 Unauthorized
- Logs show "Invalid signature" errors
- Linear's webhook delivery logs show 401 responses
Causes & Solutions:
❌ Cause: Using Wrong Webhook Secret
// WRONG: Using API key instead of webhook secret
const secret = process.env.LINEAR_API_KEY; // API key is different!
// CORRECT: Use webhook secret from webhook creation
const secret = process.env.LINEAR_WEBHOOK_SECRET;
Solution: Verify you're using the correct webhook secret. Check Linear webhook settings or regenerate the secret.
❌ Cause: Parsing JSON Before Verification
// WRONG: Body is modified by express.json()
app.use(express.json());
app.post('/webhooks/linear', (req, res) => {
const body = JSON.stringify(req.body); // Re-stringified, won't match!
const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
});
// CORRECT: Use raw body
app.use('/webhooks/linear', express.raw({type: 'application/json'}));
app.post('/webhooks/linear', (req, res) => {
const signature = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
// req.body is Buffer, matches Linear's signature
});
Solution: Use express.raw() middleware instead of express.json() for webhook routes.
❌ Cause: Incorrect Signature Encoding
// WRONG: Missing encoding parameter
const signature = crypto.createHmac('sha256', secret).update(body).digest();
// CORRECT: Hex encoding (lowercase)
const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
Solution: Ensure you're generating hex-encoded signatures (not base64).
❌ Cause: Not Using Timing-Safe Comparison
// WRONG: Vulnerable to timing attacks
if (signature === expectedSignature) { }
// CORRECT: Timing-safe comparison
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { }
Solution: Use crypto.timingSafeEqual() in Node.js or hmac.compare_digest() in Python.
Debugging Steps:
- Log the received signature and expected signature (first 16 chars only):
console.log('Received signature:', signature.substring(0, 16) + '...');
console.log('Expected signature:', expectedSignature.substring(0, 16) + '...');
- Verify your webhook secret:
echo $LINEAR_WEBHOOK_SECRET
# Should start with "whs_"
- Test with our Webhook Payload Generator:
- Generate a test payload with your secret
- Send to your endpoint
- If test payload works, issue is with Linear setup; if it fails, issue is in your code
Issue 2: Webhook Timeouts
Symptoms:
- Linear's delivery logs show timeout errors
- Webhooks marked as failed after 5 seconds
- Webhook disabled after repeated timeouts
Causes & Solutions:
❌ Cause: Slow Database Queries Blocking Response
// WRONG: Blocking response with slow query
app.post('/webhooks/linear', async (req, res) => {
const payload = verifyAndParse(req);
// This query takes 8 seconds - Linear times out!
await db.issues.update({ linearId: payload.data.id }, payload.data);
res.status(200).send('Done'); // Never reached
});
// CORRECT: Queue for async processing
app.post('/webhooks/linear', async (req, res) => {
const payload = verifyAndParse(req);
// Return 200 immediately
res.status(200).send('Queued');
// Process in background
await webhookQueue.add('process', payload);
});
Solution: Return 200 immediately after signature verification, process webhook asynchronously.
❌ Cause: External API Calls Taking Too Long
// WRONG: Waiting for Slack API (could take 3-10 seconds)
app.post('/webhooks/linear', async (req, res) => {
const payload = verifyAndParse(req);
await sendSlackNotification(payload); // Blocks response
res.status(200).send('Done');
});
// CORRECT: Queue external API calls
app.post('/webhooks/linear', async (req, res) => {
const payload = verifyAndParse(req);
res.status(200).send('Queued'); // Return immediately
// Call Slack in background
notificationQueue.add('slack', payload);
});
Solution: Move all external API calls to background workers.
❌ Cause: Complex Business Logic Taking Too Long
// WRONG: Heavy processing in webhook handler
app.post('/webhooks/linear', async (req, res) => {
const payload = verifyAndParse(req);
// Generate analytics, update 5 systems, send 3 notifications...
await performComplexProcessing(payload); // Takes 12 seconds
res.status(200).send('Done');
});
// CORRECT: Minimal processing, queue the rest
app.post('/webhooks/linear', async (req, res) => {
const payload = verifyAndParse(req);
// Only record receipt (< 100ms)
await db.webhookEvents.create({ webhookId: payload.webhookId });
res.status(200).send('Queued');
// Complex processing in background
processingQueue.add('complex', payload);
});
Solution: Webhook handler should only verify signature, check idempotency, and queue—nothing else.
Performance Target:
Aim for < 500ms response time (Linear's timeout is 5 seconds, but faster is better):
app.post('/webhooks/linear', async (req, res) => {
const startTime = Date.now();
// Verify, check idempotency, queue
const duration = Date.now() - startTime;
console.log(`Webhook processed in ${duration}ms`);
// Alert if too slow
if (duration > 500) {
alertOps('Slow webhook response', { duration, webhookId });
}
res.status(200).send('Queued');
});
Issue 3: Duplicate Events
Symptoms:
- Same webhook processed multiple times
- Duplicate database records
- Multiple notifications sent for single event
- Logs show same
webhookIdprocessed repeatedly
Causes & Solutions:
❌ Cause: No Idempotency Check
// WRONG: Process every webhook (including duplicates)
app.post('/webhooks/linear', async (req, res) => {
const payload = verifyAndParse(req);
// No duplicate check - processes same event multiple times
await createIssue(payload.data);
res.status(200).send('Done');
});
// CORRECT: Check if already processed
app.post('/webhooks/linear', async (req, res) => {
const payload = verifyAndParse(req);
const existing = await db.webhookEvents.findOne({
webhookId: payload.webhookId
});
if (existing) {
console.log('Duplicate webhook, skipping');
return res.status(200).json({ duplicate: true });
}
// Record and process
await db.webhookEvents.create({ webhookId: payload.webhookId });
await processWebhook(payload);
res.status(200).send('Processed');
});
Solution: Track webhookId in your database and skip if already processed.
❌ Cause: Non-Idempotent Operations
// WRONG: Creates duplicate records on retry
await db.notifications.create({ issueId: payload.data.id, sent: true });
// CORRECT: Use upsert or idempotent operations
await db.notifications.upsert({
where: { issueId: payload.data.id },
update: { sent: true, sentAt: new Date() },
create: { issueId: payload.data.id, sent: true, sentAt: new Date() }
});
Solution: Use upsert operations or check existence before creating records.
Why Duplicates Happen:
Linear retries failed webhooks up to 3 times:
- First attempt: Your server responds with 500 (database timeout)
- Retry after 1 minute: You return 200 but webhook was already partially processed
- Retry after 1 hour: Webhook fully processed, creates duplicate data
Idempotency prevents this by detecting the webhook was already handled.
Issue 4: Missing Webhooks
Symptoms:
- Expected webhooks never arrive
- Linear's delivery logs show success but endpoint not hit
- Some events received, others missing
- Webhook appears enabled in Linear settings
Causes & Solutions:
❌ Cause: Firewall Blocking Linear's IP Addresses
Solution: Whitelist Linear's webhook IPs in your firewall:
35.231.147.226
35.243.134.228
34.140.253.14
34.38.87.206
34.134.222.122
35.222.25.142
❌ Cause: Wrong Webhook URL in Linear Settings
// Check your webhook URL in Linear settings
// WRONG: Typo in URL
https://api.yourdomain.com/webhoooks/linear
// CORRECT: Verify URL is correct
https://api.yourdomain.com/webhooks/linear
Solution: Double-check webhook URL in Linear's webhook settings. Test with curl:
curl -X POST https://api.yourdomain.com/webhooks/linear \
-H "Content-Type: application/json" \
-d '{"test": true}'
# Should return 200 (or 401 if signature missing)
❌ Cause: SSL Certificate Issues
Linear requires valid HTTPS certificates. Self-signed or expired certificates will cause delivery failures.
Solution: Verify your SSL certificate:
# Check SSL certificate validity
curl -vI https://api.yourdomain.com/webhooks/linear
# Look for:
# SSL certificate verify ok
# Certificate expiration date
Use valid certificates from Let's Encrypt, Cloudflare, or commercial CAs.
❌ Cause: Webhook Disabled After Repeated Failures
If your endpoint fails consistently, Linear may auto-disable the webhook.
Solution: Check webhook status in Linear settings:
- Navigate to Settings → API → Webhooks
- Look for status indicator (enabled/disabled)
- Review delivery logs for failure patterns
- Fix underlying issue (timeouts, signature errors, etc.)
- Re-enable webhook
❌ Cause: Event Type Not Subscribed
Webhooks only fire for subscribed resource types.
Solution: Verify webhook is subscribed to the events you expect:
// Check webhook configuration via API
query {
webhooks {
nodes {
id
url
enabled
resourceTypes
team { name }
}
}
}
// Response
{
"data": {
"webhooks": {
"nodes": [{
"resourceTypes": ["Issue", "Comment"] // Only these events will be sent
}]
}
}
}
Add missing resource types in Linear's webhook settings.
Issue 5: Webhooks Working in Test but Failing in Production
Symptoms:
- ngrok testing works perfectly
- Production deployment fails
- Same code, different behavior
Causes & Solutions:
❌ Cause: Different Environment Variables
// Test environment
LINEAR_WEBHOOK_SECRET=whs_test_1234567890abcdef
// Production environment
LINEAR_WEBHOOK_SECRET=whs_prod_9876543210fedcba
// Using wrong secret in production!
Solution: Verify environment variables are set correctly in production:
# SSH into production server
echo $LINEAR_WEBHOOK_SECRET
# Should match webhook secret from Linear production webhook
❌ Cause: Production Firewall Rules
Your production server may have stricter firewall rules than your test environment.
Solution: Add Linear's IPs to production firewall whitelist.
❌ Cause: Load Balancer / Reverse Proxy Issues
Load balancers may modify request bodies or strip headers.
Solution: Configure load balancer to preserve raw body:
# Nginx configuration
location /webhooks/linear {
proxy_pass http://app-server;
# Preserve headers
proxy_set_header Linear-Signature $http_linear_signature;
proxy_set_header Linear-Event $http_linear_event;
proxy_set_header Linear-Delivery $http_linear_delivery;
# Don't buffer (preserves raw body)
proxy_request_buffering off;
}
Debugging Checklist
When troubleshooting Linear webhook issues, follow this checklist:
Connection & Delivery:
- Webhook URL is correct and publicly accessible
- HTTPS certificate is valid (not self-signed or expired)
- Firewall allows traffic from Linear's IP addresses
- Webhook status is "enabled" in Linear settings
- Linear's delivery logs show successful delivery (200 response)
Signature Verification:
- Using correct webhook secret (not API key)
- Using raw request body (not parsed/restringified JSON)
- Signature algorithm is HMAC-SHA256 with hex encoding
- Using timing-safe comparison function
- Webhook secret matches Linear's webhook settings
Performance:
- Endpoint responds within 5 seconds (preferably < 500ms)
- Return 200 immediately, process asynchronously
- No blocking database queries in webhook handler
- No blocking external API calls in webhook handler
- Queue system configured and running
Reliability:
- Idempotency check implemented (track
webhookId) - Database records use upsert or check-then-create
- Failed webhooks are retried automatically
- Reconciliation job catches missed webhooks
- Comprehensive error logging enabled
Monitoring:
- Webhook processing metrics tracked
- Signature verification failures alerted
- Queue depth monitored
- Health check endpoint available
- Production logs accessible
Testing:
- Test with Webhook Payload Generator
- Test signature verification with known-good payload
- Test duplicate webhook handling
- Test with various event types
- Test error scenarios (malformed payloads, missing fields)
If issues persist after checking this list, review Linear's webhook delivery logs for detailed error messages and consult Linear's developer documentation for the latest requirements.
Frequently Asked Questions
Q: How often does Linear send webhooks?
A: Linear sends webhooks immediately when events occur, typically within milliseconds. There is no batching or delay—webhooks are delivered in real-time as data changes in your workspace. If delivery fails, Linear will retry up to 3 times with exponential backoff (1 minute, 1 hour, 6 hours between attempts). After 3 failed attempts, the webhook delivery is marked as failed and no further retries occur, though the webhook itself remains enabled unless manually disabled.
Q: Can I receive webhooks for past events?
A: No, Linear webhooks are only sent for events that occur after the webhook is created. You cannot retroactively receive webhooks for historical data. If you need to import past data, use Linear's GraphQL API to query historical issues, comments, projects, and other entities. You can fetch data with filters like updatedAt: { gt: "2024-01-01" } to get recent changes and backfill your database during initial integration setup.
Q: What happens if my endpoint is down?
A: Linear will retry failed webhook deliveries up to 3 times:
- First retry: 1 minute after initial failure
- Second retry: 1 hour after first retry
- Third retry: 6 hours after second retry
After 3 failed attempts, the delivery is abandoned. If your endpoint experiences repeated failures across multiple webhook deliveries, Linear may automatically disable the webhook, requiring manual re-enabling in your organization settings. To prevent missing webhooks during downtime, implement a reconciliation process that periodically fetches recent changes from Linear's API to catch any missed events.
Q: Do I need different endpoints for test and production?
A: Yes, it's strongly recommended to use separate webhook URLs for development, staging, and production environments, each with unique webhook secrets. This prevents test data from affecting production systems and allows independent testing without risk. Create separate webhooks in Linear for each environment:
Development: https://abc123.ngrok.io/webhooks/linear (secret: whs_dev_...)
Staging: https://staging.yourdomain.com/webhooks/linear (secret: whs_staging_...)
Production: https://api.yourdomain.com/webhooks/linear (secret: whs_prod_...)
Store secrets in environment-specific configuration (.env.development, .env.production, etc.) and never commit them to version control.
Q: How do I handle webhook ordering?
A: Linear does not guarantee webhooks are delivered in chronological order. Due to network latency, retries, and distributed systems, an "Issue Updated" webhook might arrive before the corresponding "Issue Created" webhook. Design your webhook handler to be order-independent:
- Use timestamps: Check
updatedAtfields to determine which event is newer - Handle missing dependencies: If an update webhook arrives for a non-existent issue, create the issue from the update data
- Implement idempotency: Process each webhook independently based on its
webhookId - Don't assume causality: Event B arriving after Event A doesn't mean A caused B
Example:
async function handleIssueUpdate(payload) {
let issue = await db.issues.findOne({ linearId: payload.data.id });
if (!issue) {
// Create issue from update event (out-of-order delivery)
issue = await db.issues.create(payload.data);
} else if (new Date(payload.data.updatedAt) > new Date(issue.updatedAt)) {
// Only update if this event is newer than what we have
await db.issues.update({ linearId: payload.data.id }, payload.data);
}
}
Q: Can I filter which events I receive?
A: Yes, when creating a webhook in Linear's settings, you can select specific resource types to subscribe to:
- Issue
- Comment
- Project
- Cycle
- IssueLabel
- Reaction
- IssueAttachment
- ProjectUpdate
- Document
- Customer
- Issue SLA
Only subscribe to events you need to reduce webhook volume, processing overhead, and potential rate limiting. You can update subscriptions anytime by editing the webhook in Linear's settings. For even finer control, you can create multiple webhooks with different resource types pointing to different endpoints (e.g., one for issues/comments, another for projects/cycles).
Q: How do I debug webhook signature verification issues?
A: Follow these debugging steps:
-
Verify you're using the webhook secret (not Linear's API key). Webhook secrets start with
whs_and are shown once during webhook creation. -
Log the signature comparison:
console.log('Header signature:', req.headers['linear-signature'].substring(0, 16) + '...');
console.log('Expected signature:', expectedSignature.substring(0, 16) + '...');
- Ensure raw body parsing:
// Verify req.body is a Buffer, not parsed JSON
console.log('Body type:', typeof req.body); // Should be 'object' (Buffer)
console.log('Is Buffer:', Buffer.isBuffer(req.body)); // Should be true
-
Test with known-good payload: Use our Webhook Payload Generator to create a test payload with your secret. If the generated payload verifies successfully but Linear's webhooks fail, the issue is with Linear's webhook configuration or your secret. If both fail, the issue is in your verification code.
-
Check webhook secret in Linear: Navigate to Settings → API → Webhooks → [Your Webhook] → Secret to view or regenerate the secret.
Q: What are Linear's webhook rate limits?
A: Linear does not publish explicit rate limits for webhook deliveries. However, there's an implicit limit based on how quickly events occur in your workspace (issues created/updated, comments added, etc.). For typical usage (hundreds to thousands of events per day), you won't encounter rate limiting. If your endpoint consistently returns errors or times out, Linear may automatically disable the webhook to prevent overwhelming your system. Ensure your endpoint can handle peak load (e.g., bulk issue imports triggering many webhooks) by using async queue-based processing.
Q: Can I test webhooks without Linear sending them?
A: Yes, use our Webhook Payload Generator to create test webhook payloads with valid signatures:
- Visit /tools/webhook-payload-generator
- Select "Linear" as the provider
- Choose event type (Issue Created, Comment Created, etc.)
- Customize payload fields
- Enter your webhook secret
- Generate signed payload
- Send to your endpoint using curl or Postman
This allows you to test signature verification, payload parsing, error handling, and business logic without needing a Linear workspace or triggering real events. Perfect for unit tests, local development, and CI/CD pipelines.
Q: How do Linear webhooks compare to polling the API?
A:
| Aspect | Webhooks | API Polling |
|---|---|---|
| Latency | Milliseconds (real-time) | Minutes (polling interval) |
| API Calls | Zero (Linear calls you) | Constant (every poll interval) |
| Efficiency | High (only receive changes) | Low (check even if no changes) |
| Complexity | Moderate (webhook endpoint + signature verification) | Low (just API calls) |
| Reliability | High (retry mechanism + reconciliation) | High (always up-to-date with API) |
| Rate Limits | None (webhook deliveries) | Yes (API calls counted) |
| Best For | Real-time reactions, notifications, automation | Historical data, batch processing, reconciliation |
Recommendation: Use webhooks for real-time processing and API polling for reconciliation/backups. Many integrations use both: webhooks for immediate reactions, hourly API polling to catch any missed webhooks.
Next Steps & Resources
You now have a comprehensive understanding of Linear webhooks, from basic setup to production-ready implementations. Here's how to put this knowledge into action and continue learning.
Try It Yourself
Follow these steps to implement your first Linear webhook integration:
1. Set Up Your Webhook Endpoint
Create a basic webhook handler using the code examples in the Implementation Example section. Start simple:
- Accept POST requests
- Verify signatures
- Return 200 immediately
- Log the payload
2. Test Locally with Our Tool
Before connecting to Linear, test your signature verification logic:
- Visit our Webhook Payload Generator
- Select "Linear" and choose an event type
- Generate a signed payload
- Send it to your local endpoint
3. Expose Your Endpoint
Use ngrok to create a public URL:
ngrok http 3000
4. Create Webhook in Linear
Navigate to Settings → API → Webhooks and create a webhook pointing to your ngrok URL. Select the event types you need.
5. Trigger Test Events
Create or update an issue in Linear and watch your endpoint receive the webhook in real-time.
6. Implement Business Logic
Add handlers for different event types based on your use case (notifications, syncing, analytics, etc.).
7. Deploy to Production
Deploy your webhook handler with proper monitoring, error handling, and queue-based processing as shown in the Implementation Example.
Additional Resources
Official Linear Documentation:
- Linear Webhook Documentation - Official webhook reference
- Linear GraphQL API - Complete API documentation
- Linear API Schema Explorer - Browse all GraphQL types and fields
- Linear TypeScript SDK - Official SDK with webhook helpers
- Linear Changelog - API updates and new features
Related Guides from InventiveHQ:
- Webhooks Explained: Complete Guide - Comprehensive webhook fundamentals
- Webhook Signature Verification Guide - Deep dive into signature security
- Testing Webhooks Locally with ngrok - Local development setup
Developer Tools:
- Webhook Payload Generator - Test Linear webhooks without Linear account
- Linear CLI Importer - Import data from other tools
- Linear OAuth Flow Guide - Build OAuth integrations
Community & Support:
- Linear GitHub Issues - Report SDK bugs and feature requests
- Linear Community - Connect with other Linear developers
- Linear Status Page - Check API availability and incidents
Real-World Integration Examples
Learn from these common Linear webhook use cases:
1. Slack Notifications
Send real-time issue updates to Slack:
if (payload.type === 'Issue' && payload.action === 'create') {
await slack.chat.postMessage({
channel: '#engineering',
text: `New issue: ${payload.data.title}`,
attachments: [{
color: payload.data.priority === 1 ? 'danger' : 'good',
fields: [
{ title: 'Assignee', value: payload.actor.name, short: true },
{ title: 'Priority', value: getPriorityLabel(payload.data.priority), short: true }
],
actions: [{ type: 'button', text: 'View in Linear', url: payload.url }]
}]
});
}
2. Automated CI/CD Triggers
Start builds when issues move to "In Progress":
if (payload.type === 'Issue' && payload.action === 'update') {
const newState = await getStateName(payload.data.stateId);
if (newState === 'In Progress') {
await triggerGitHubWorkflow({
repo: 'yourorg/yourrepo',
workflow: 'build-and-test.yml',
ref: 'main',
inputs: { issueId: payload.data.id }
});
}
}
3. CRM Integration
Sync Linear issues with Salesforce opportunities:
if (payload.type === 'Issue' && payload.data.labelIds.includes(CUSTOMER_REQUEST_LABEL)) {
await salesforce.opportunity.create({
Name: payload.data.title,
Description: payload.data.description,
Linear_Issue__c: payload.data.id,
Linear_URL__c: payload.url
});
}
4. Time Tracking
Track time spent in each workflow state:
if (payload.type === 'Issue' && payload.action === 'update' && payload.updatedFrom?.stateId) {
const duration = Date.now() - new Date(payload.updatedFrom.updatedAt).getTime();
await analytics.track({
event: 'issue_state_duration',
properties: {
issueId: payload.data.id,
fromState: await getStateName(payload.updatedFrom.stateId),
toState: await getStateName(payload.data.stateId),
durationMs: duration
}
});
}
Need Help?
If you encounter issues or have questions:
- Check Troubleshooting Section: Review the Common Issues & Troubleshooting section above
- Test with Our Tool: Use the Webhook Payload Generator to isolate issues
- Review Linear's Logs: Check webhook delivery logs in Settings → API → Webhooks → [Your Webhook] → Recent Deliveries
- Consult Linear's Docs: Visit developers.linear.app for official documentation
- Contact Linear Support: For Linear-specific issues, reach out via their community or support channels
- Contact Us: For questions about our webhook guides or tools, reach out to InventiveHQ
Conclusion
Linear webhooks provide a powerful, real-time way to integrate your project management workflows with external systems, automate routine tasks, and build custom integrations tailored to your team's needs. By following this guide, you now know how to:
- ✅ Set up Linear webhooks in your organization using the web interface or GraphQL API
- ✅ Verify webhook signatures securely with HMAC-SHA256 to prevent spoofing and tampering
- ✅ Implement production-ready webhook endpoints with async processing, idempotency, and error handling
- ✅ Handle common issues like signature verification failures, timeouts, and duplicate events
- ✅ Test webhooks effectively using ngrok and our Webhook Payload Generator
Remember the key principles for reliable Linear webhook integrations:
- Always verify signatures using HMAC-SHA256 with timing-safe comparison to ensure authenticity
- Respond quickly (within 5 seconds, ideally < 500ms) by returning 200 immediately and processing asynchronously
- Process asynchronously using queue systems to avoid blocking the webhook response
- Implement idempotency by tracking
webhookIdto handle retries and duplicate deliveries gracefully
Linear's webhook system, combined with its GraphQL API, enables sophisticated real-time integrations—from simple Slack notifications to complex multi-system workflows. Whether you're syncing issues with external tools, triggering automated builds, tracking team velocity, or building custom dashboards, webhooks provide the foundation for efficient, scalable integrations.
Start building with Linear webhooks today, and use our Webhook Payload Generator to test your integration without needing to trigger real Linear events. With proper signature verification, async processing, and idempotency checks, you'll create reliable integrations that scale with your team's growth.
Ready to get started? Set up your first Linear webhook now and experience the power of real-time project management automation.
Have questions or feedback? Drop a comment below, contact us, or explore our other webhook guides for integrations with Stripe, GitHub, Shopify, and more.
Sources:
