Skip to main content
Generalmarkus41

Linear Webhooks (Verify, Replay, DLQ)

This skill should be used when registering, verifying, or processing Linear webhooks — HMAC signatures, replay protection, idempotency, dead-letter queues. Activates on "linear webhook", "webhook signature", "Linear-Signature", "webhook secret".

Stars
12
Source
markus41/claude
Updated
2026-05-11
Slug
markus41--claude--linear-webhooks
View on GitHubRaw SKILL.md

// install — copy + paste into any project

mkdir -p .claude/skills && curl -fsSL https://raw.githubusercontent.com/markus41/claude/HEAD/plugins/linear-orchestrator/skills/linear-webhooks/SKILL.md -o .claude/skills/linear-webhooks.md

Drops the SKILL.md into .claude/skills/linear-webhooks.md. Works with Claude Code, Cursor, and any agent that loads SKILL.md files from .claude/skills/.

Linear Webhooks

Reference: https://linear.app/developers/webhooks

Signature verification

Linear signs every delivery with HMAC-SHA256:

Linear-Signature: <hex digest>

Verify in constant time:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyLinearSignature(rawBody: Buffer, signature: string, secret: string): boolean {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(signature, "hex");
  const b = Buffer.from(expected, "hex");
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

Always read the raw body bytes, not the parsed JSON. Express:

app.use("/linear/webhook", express.raw({ type: "application/json" }));

Replay protection

Each delivery has a webhookTimestamp field in the JSON body (Unix ms). Reject events older than 5 minutes:

if (Math.abs(Date.now() - body.webhookTimestamp) > 5 * 60_000) reject();

Idempotency

Linear may re-deliver. Each event has:

  • delivery.id — unique per delivery (use this!)
  • data.id — entity ID

Store seen delivery.id in Redis with 7-day TTL; ignore duplicates.

Resource types

Issue, IssueLabel, Comment, Cycle, Project, ProjectUpdate, Initiative, InitiativeUpdate, Customer, CustomerNeed, Reaction, Attachment, Document.

Subscribe selectively — fewer types means smaller event volume.

Action types

create | update | remove. Some resources support more; consult the schema.

Body shape

{
  "action": "update",
  "actor": { "id": "...", "name": "..." },
  "createdAt": "2026-04-30T12:00:00.000Z",
  "data": { /* the resource */ },
  "type": "Issue",
  "url": "https://linear.app/...",
  "webhookTimestamp": 1714478400000,
  "webhookId": "...",
  "delivery": { "id": "..." }
}

Re-fetch on demand

Don't trust webhook payload state for reads. Linear may send out-of-order events. After receiving an Issue update, re-fetch via GraphQL using the id to get the canonical state.

Dead-Letter Queue

Implementation in lib/webhook-dlq.ts:

  • After 3 failed processings, write to DLQ table with: delivery ID, payload, error, attempts
  • /linear:webhook dlq lists; /linear:webhook replay --since 24h retries from DLQ
  • Alert (Slack / PagerDuty) when DLQ depth > 10

Local testing

Use ngrok http 3000 and set the public URL as the webhook URL. Linear has no built-in test-replay UI; use webhookTest mutation if available, or the DLQ replay path.

Webhook security checklist

  • HTTPS only
  • Signature verified before any body parsing beyond raw read
  • 5-minute timestamp window
  • delivery.id idempotency
  • Re-fetch authoritative state via GraphQL
  • DLQ with bounded retry
  • Webhook secret rotated yearly