Agent skill

Linear Webhooks Skill

Receive and verify Linear webhooks. Use when setting up Linear webhook handlers, debugging Linear signature verification, or handling Linear issue tracking events like Issue, Comment, Project, Cycle, IssueLabel, and IssueSLA create/update/remove actions.

Install this skill

npx skills add hookdeck/webhook-skills --skill linear-webhooks


When to Use This Skill

  • Setting up Linear webhook handlers
  • Debugging Linear signature verification failures
  • Validating the Linear-Signature HMAC-SHA256 header
  • Handling Linear Issue, Comment, Project, Cycle, IssueLabel, or IssueSLA events
  • Reacting to create, update, and remove actions on Linear entities
  • Rejecting stale webhook deliveries via the webhookTimestamp field

Essential Code (USE THIS)

Linear Signature Verification (JavaScript)

Linear signs each webhook with HMAC-SHA256 over the raw request body, hex-encoded, sent in the Linear-Signature header. Linear has no first-party Node SDK helper for verifying webhooks, so manual verification is the recommended approach.

const crypto = require('crypto');

function verifyLinearWebhook(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  // HMAC-SHA256(rawBody, secret) → hex
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signatureHeader, 'hex'),
      Buffer.from(expected, 'hex')
    );
  } catch {
    return false;
  }
}

// Reject deliveries older than 1 minute (replay protection)
function isFreshTimestamp(webhookTimestamp) {
  if (typeof webhookTimestamp !== 'number') return false;
  const skewMs = Math.abs(Date.now() - webhookTimestamp);
  return skewMs <= 60 * 1000;
}

Express Webhook Handler

const express = require('express');
const app = express();

// CRITICAL: Use express.raw() - Linear signs the raw body
app.post('/webhooks/linear',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['linear-signature'];
    const event = req.headers['linear-event'];      // e.g. "Issue", "Comment"
    const delivery = req.headers['linear-delivery']; // UUID for idempotency

    if (!verifyLinearWebhook(req.body, signature, process.env.LINEAR_WEBHOOK_SECRET)) {
      return res.status(400).send('Invalid signature');
    }

    const payload = JSON.parse(req.body.toString());

    // Linear requires rejecting deliveries older than 1 minute
    if (!isFreshTimestamp(payload.webhookTimestamp)) {
      return res.status(400).send('Stale webhook');
    }

    console.log(`Linear ${event} ${payload.action} (delivery: ${delivery})`);

    switch (event) {
      case 'Issue':
        console.log(`Issue ${payload.action}:`, payload.data?.title);
        break;
      case 'Comment':
        console.log(`Comment ${payload.action} on issue ${payload.data?.issueId}`);
        break;
      case 'Project':
        console.log(`Project ${payload.action}:`, payload.data?.name);
        break;
      case 'IssueSLA':
        console.log(`SLA event on issue ${payload.issueData?.id}`);
        break;
      default:
        console.log(`Unhandled Linear event: ${event}`);
    }

    res.status(200).send('OK');
  }
);

Python Signature Verification (FastAPI)

import hmac
import hashlib
import time

def verify_linear_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header, expected)

def is_fresh_timestamp(webhook_timestamp_ms: int) -> bool:
    if not isinstance(webhook_timestamp_ms, int):
        return False
    now_ms = int(time.time() * 1000)
    return abs(now_ms - webhook_timestamp_ms) <= 60_000

For complete working examples with tests, see:

Common Linear-Event Header Values

Linear-EventTriggered When
IssueIssue created, updated, or removed
CommentComment created, updated, or removed
IssueLabelLabel created, updated, or removed
ProjectProject created, updated, or removed
ProjectUpdateProject update posted
CycleCycle created, updated, or removed
ReactionReaction added or removed
DocumentDocument created, updated, or removed
InitiativeInitiative created, updated, or removed
InitiativeUpdateInitiative update posted
CustomerCustomer record changed
CustomerRequestCustomer request created/updated
UserUser changed
IssueSLASLA set, highRisk, or breached for an issue
OAuthAppRevokedOAuth app permissions revoked

For the full event reference, see Linear's webhook documentation.

Common Action Values

Data change events (Issue, Comment, Project, …) send one of:

actionMeaning
createEntity created
updateEntity updated (updatedFrom contains previous values)
removeEntity deleted

IssueSLA and OAuthAppRevoked use event-specific actions (e.g. set, highRisk, breached).

Important Headers

HeaderDescription
Linear-SignatureHMAC-SHA256 of raw body, hex encoded
Linear-EventEntity type (e.g. Issue, Comment, Project)
Linear-DeliveryUUID v4 unique to the delivery — use for idempotency
Content-Typeapplication/json; charset=utf-8
User-AgentLinear-Webhook

Environment Variables

LINEAR_WEBHOOK_SECRET=your_webhook_secret   # Shown once when the webhook is created in Linear

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 linear --path /webhooks/linear

Use the printed Hookdeck URL as the webhook URL when creating the webhook in Linear's API settings.

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 1, 2026

View on GitHub →