# Guide to Linear Webhooks: Features and Best Practices

Linear webhooks notify your application when issues, comments, projects, and cycles change. If you're syncing Linear with another system, triggering automations, or mirroring issues, webhooks are how you react to changes without polling the API.

This guide covers how Linear webhooks work, the events you'll handle, how to verify the `Linear-Signature`, and the best practices for production.

## What are Linear webhooks?

Linear webhooks are JSON POSTs delivered over HTTPS to a URL you register in your workspace settings. Each is signed with a `Linear-Signature` header: an HMAC-SHA256 over the raw request body, hex-encoded. Unlike GitHub, Linear does not prefix the value with `sha256=`, it's a bare hex string. The payload also carries a `webhookTimestamp` field (Unix milliseconds) so you can reject stale deliveries, which gives you replay protection out of the box.

## Linear webhook features

| Feature | Details |
| --- | --- |
| Configuration | Workspace settings > API > Webhooks (or per OAuth app) |
| Signature header | `Linear-Signature` (bare hex, no `sha256=` prefix) |
| Signature scheme | HMAC-SHA256 (hex) over the raw body, keyed with the signing secret |
| Event header | `Linear-Event` (the entity type), `Linear-Delivery` (delivery UUID) |
| Replay protection | `webhookTimestamp` body field (Unix ms); reject if more than ~1 minute off |
| Delivery | Retried on failure; view and re-deliver from the webhook's page |
| SDK | None for verification (the `@linear/sdk` is GraphQL-focused); verify manually |

## Common events

Linear identifies the entity type in the `Linear-Event` header and the payload's top-level `type` field. Data-change events carry an `action` of `create`, `update` (with an `updatedFrom` object of previous values), or `remove`.

| Event | Fires when |
| --- | --- |
| `Issue` | An issue is created, updated, or removed |
| `Comment` | A comment changes |
| `IssueLabel` | A label changes |
| `Project` / `ProjectUpdate` | A project or project update changes |
| `Cycle` | A cycle changes |
| `Reaction`, `Document`, `Initiative`, `InitiativeUpdate` | Those entities change |
| `Customer`, `CustomerRequest`, `User` | Those entities change |
| `IssueSLA` | Uses SLA-specific actions: `set`, `highRisk`, `breached` |
| `OAuthAppRevoked` | Fires once when your app's authorization is revoked |

Note that `IssueSLA` doesn't use create/update/remove, it uses `set`/`highRisk`/`breached`, and `OAuthAppRevoked` fires once with neither.

## Setting up Linear webhooks

Go to Workspace settings > API > Webhooks > Create new webhook (or, for an OAuth app, Settings > API > Applications > your app > Webhooks). Set a label, an HTTPS URL, and the resource types you want. The signing secret is displayed only once, copy it immediately into `LINEAR_WEBHOOK_SECRET`. If you lose it, delete and recreate the webhook to get a new one (this is also how you rotate).

## Securing Linear webhooks

Verify against the raw body before parsing, compute an HMAC-SHA256 with your signing secret, hex-encode it, and compare in constant time. Then check that `webhookTimestamp` is within about a minute of now to reject replays.

```javascript
const crypto = require("crypto");

const SECRET = process.env.LINEAR_WEBHOOK_SECRET;
const TIMESTAMP_TOLERANCE_MS = 60 * 1000;

function verify(rawBody, signatureHeader) {
  if (!signatureHeader) return false;
  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;
  }
}

app.post("/webhooks/linear", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.headers["linear-signature"])) {
    return res.sendStatus(401);
  }

  const payload = JSON.parse(req.body.toString("utf8"));
  if (Math.abs(Date.now() - payload.webhookTimestamp) > TIMESTAMP_TOLERANCE_MS) {
    return res.sendStatus(401); // stale or replayed
  }

  res.sendStatus(200); // acknowledge fast
  processQueue.add(payload); // dedupe on Linear-Delivery, async
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

SECRET = os.environ["LINEAR_WEBHOOK_SECRET"].encode()

def verify(raw_body: bytes, signature_header: str) -> bool:
    if not signature_header:
        return False
    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header, expected)

```

## Linear webhook limitations and pain points

### The signature is bare hex with no prefix

The Problem: `Linear-Signature` is a bare hex string. Code that strips a `sha256=` prefix (as GitHub uses) or decodes base64 never matches.

Why It Happens: Linear hex-encodes the HMAC and doesn't prefix it.

Workarounds:

* Use the header value as-is, hex, over the raw body.

How Hookdeck Can Help: Hookdeck verifies the signature at the edge, so your app receives pre-verified events without matching header formats.

### `webhookTimestamp` is in milliseconds

The Problem: The replay check compares `webhookTimestamp` to now, but it's in milliseconds, not seconds. Comparing against a seconds clock rejects everything.

Why It Happens: Linear reports the timestamp in Unix milliseconds.

Workarounds:

* Compare against `Date.now()` (ms), and use an absolute skew so you also reject far-future timestamps.

How Hookdeck Can Help: Hookdeck enforces freshness at the edge, so your app receives events that are already within a sane window.

### The secret is shown once

The Problem: Linear reveals the signing secret only at creation. Lose it and there's no way to view it again.

Why It Happens: Linear stores the secret write-only after creation.

Workarounds:

* Copy it into `LINEAR_WEBHOOK_SECRET` immediately; recreate the webhook to rotate.

How Hookdeck Can Help: Hookdeck holds the secret and verifies centrally, so it lives in one place.

### Retries mean duplicates

The Problem: Linear retries failed deliveries, so the same event can arrive more than once. The signature proves authenticity, not uniqueness.

Why It Happens: At-least-once delivery with retries.

Workarounds:

* Dedupe on the `Linear-Delivery` UUID and make handlers idempotent.

How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge, so retries don't double-process. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Best practices

### Verify HMAC-SHA256 over the raw body

Compute the hex HMAC over the raw body with your signing secret and compare against `Linear-Signature` in constant time.

### Enforce the timestamp window

Reject deliveries whose `webhookTimestamp` is more than about a minute from now.

### Dedupe on Linear-Delivery

Persist the delivery UUID and skip repeats so retries are safe.

### Acknowledge fast, process asynchronously

Return 200 quickly and defer work to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Linear webhooks are verified with a `Linear-Signature` HMAC-SHA256 over the raw body, hex-encoded with no prefix, and carry a `webhookTimestamp` (milliseconds) for replay protection. Verify over the raw body, enforce the timestamp window, dedupe on `Linear-Delivery`, and handle the broad set of entity events with their per-type actions.

[Hookdeck](https://hookdeck.com) verifies the signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Linear webhooks reliably in minutes.