Home/Blog/Linear Webhooks: Complete Guide with Payload Examples [2025]
Developer Tools

Linear Webhooks: Complete Guide with Payload Examples [2025]

Complete guide to Linear webhooks with setup instructions, payload examples, signature verification, and implementation code. Learn how to integrate Linear webhooks into your application with step-by-step tutorials for real-time project management automation.

By Inventive HQ Team
Linear Webhooks: Complete Guide with Payload Examples [2025]

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 admin scope
  • 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 TypeDescription
IssueIssue created, updated, or removed
CommentComments added, edited, or deleted
ProjectProject changes and updates
CycleSprint/cycle modifications
IssueLabelLabel assignments and changes
ReactionEmoji reactions to comments
IssueAttachmentFile attachments on issues
ProjectUpdateProject status update posts
DocumentDocument changes
CustomerCustomer-related events
Issue SLASLA 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/linear not /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:

  1. Accepts POST requests with Content-Type: application/json
  2. Returns HTTP 200 status code
  3. Responds within 5 seconds
  4. Verifies the Linear-Signature header (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 TypeActionsCommon Use Cases
Issuecreate, update, removeTrigger CI builds, notify stakeholders, update external trackers
Commentcreate, update, removeSync discussions to Slack, track feedback, analyze sentiment
Projectcreate, update, removeDashboard updates, milestone tracking, timeline management
Cyclecreate, update, removeSprint planning automation, velocity tracking, burndown charts
IssueLabelcreate, update, removeCategory analytics, automated tagging, workflow routing
Reactioncreate, removeEngagement tracking, sentiment analysis, team morale metrics
IssueAttachmentcreate, update, removeFile management, documentation syncing, backup automation
ProjectUpdatecreate, update, removeStatus report distribution, stakeholder notifications
Documentcreate, update, removeKnowledge base syncing, documentation versioning
Customercreate, update, removeCRM integration, customer feedback tracking
Issue SLAbreaching, breachedAlert 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 issues
  • type - Resource type (Issue)
  • actor - User who created the issue with full profile details
  • data.id - Unique issue identifier (UUID)
  • data.number - Human-readable issue number (e.g., ENG-123)
  • data.title - Issue title/summary
  • data.priority - Priority level (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low)
  • data.estimate - Story points or time estimate
  • data.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 sent
  • url - 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 issues
  • updatedFrom - Object containing previous values of changed fields only
  • data - Current state of the issue after the update
  • actor - 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 identifier
  • data.userId - Comment author
  • url - 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 range
  • data.progress - Completion percentage (0.0 to 1.0)
  • data.scopeHistory - Array tracking total scope points over time
  • data.completedScopeHistory - Array tracking completed points for burndown charts

Common Webhook Patterns Across All Events:

  1. Consistent Structure: All webhooks include action, type, actor, data, createdAt, and url fields
  2. Update Tracking: Update events always include updatedFrom with previous values
  3. GraphQL Alignment: Payload structures match Linear's GraphQL schema
  4. ID References: Related objects (teams, users, projects) provided as IDs, not nested objects
  5. 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:

HeaderDescriptionExample Value
Linear-SignatureHMAC-SHA256 hex digest766e1d90a96e2f5e...
Linear-DeliveryUnique delivery UUID000042e3-d123-4980-b49f...
Linear-EventResource typeIssue
Content-TypeJSON content typeapplication/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-Signature on 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 webhookTimestamp is 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:3000
  • http://127.0.0.1:3000
  • http://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-webhookshttps://myapp-webhooks.ngrok.io
  • Replay Requests: Visit http://127.0.0.1:4040 for 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:

  1. Navigate to Settings → API → Webhooks
  2. Click on your webhook
  3. 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-Signature header
  • 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:

  1. 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) + '...');
  1. Verify your webhook secret:
echo $LINEAR_WEBHOOK_SECRET
# Should start with "whs_"
  1. 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 webhookId processed 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:

  1. First attempt: Your server responds with 500 (database timeout)
  2. Retry after 1 minute: You return 200 but webhook was already partially processed
  3. 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:

  1. Navigate to Settings → API → Webhooks
  2. Look for status indicator (enabled/disabled)
  3. Review delivery logs for failure patterns
  4. Fix underlying issue (timeouts, signature errors, etc.)
  5. 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:

  1. Use timestamps: Check updatedAt fields to determine which event is newer
  2. Handle missing dependencies: If an update webhook arrives for a non-existent issue, create the issue from the update data
  3. Implement idempotency: Process each webhook independently based on its webhookId
  4. 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:

  1. Verify you're using the webhook secret (not Linear's API key). Webhook secrets start with whs_ and are shown once during webhook creation.

  2. Log the signature comparison:

console.log('Header signature:', req.headers['linear-signature'].substring(0, 16) + '...');
console.log('Expected signature:', expectedSignature.substring(0, 16) + '...');
  1. 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
  1. 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.

  2. 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:

  1. Visit /tools/webhook-payload-generator
  2. Select "Linear" as the provider
  3. Choose event type (Issue Created, Comment Created, etc.)
  4. Customize payload fields
  5. Enter your webhook secret
  6. Generate signed payload
  7. 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:

AspectWebhooksAPI Polling
LatencyMilliseconds (real-time)Minutes (polling interval)
API CallsZero (Linear calls you)Constant (every poll interval)
EfficiencyHigh (only receive changes)Low (check even if no changes)
ComplexityModerate (webhook endpoint + signature verification)Low (just API calls)
ReliabilityHigh (retry mechanism + reconciliation)High (always up-to-date with API)
Rate LimitsNone (webhook deliveries)Yes (API calls counted)
Best ForReal-time reactions, notifications, automationHistorical 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:

Related Guides from InventiveHQ:

Developer Tools:

Community & Support:

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:

  1. Check Troubleshooting Section: Review the Common Issues & Troubleshooting section above
  2. Test with Our Tool: Use the Webhook Payload Generator to isolate issues
  3. Review Linear's Logs: Check webhook delivery logs in Settings → API → Webhooks → [Your Webhook] → Recent Deliveries
  4. Consult Linear's Docs: Visit developers.linear.app for official documentation
  5. Contact Linear Support: For Linear-specific issues, reach out via their community or support channels
  6. 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:

  1. Always verify signatures using HMAC-SHA256 with timing-safe comparison to ensure authenticity
  2. Respond quickly (within 5 seconds, ideally < 500ms) by returning 200 immediately and processing asynchronously
  3. Process asynchronously using queue systems to avoid blocking the webhook response
  4. Implement idempotency by tracking webhookId to 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:

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.