All articles
Test Linear Webhooks on Localhost Securely
Linearwebhookslocalhostdeveloper integrations

Test Linear Webhooks on Localhost Securely

To test Linear webhooks on localhost, run a local POST endpoint, expose it with npx portpreview PORT, register the resulting public HTTPS URL in Linear, and verify the Linear-Signature HMAC against the untouched request body. Check the payload timestamp for replay protection, record the delivery idempotently, and return HTTP 200 within five seconds.

What a realistic Linear webhook test covers

Linear webhooks notify an integration when supported workspace data changes. Typical examples include issues, comments, labels, projects, cycles, documents, initiatives, customers, and users. The available categories can evolve, and specialized streams such as OAuth app revocation or agent events have their own payload contracts, so confirm the event type against the current official Linear webhook documentation.

A request sent to localhost cannot reach your laptop from Linear's infrastructure. PortPreview provides a public HTTPS origin and forwards the request to your local port. Unlike a copied JSON fixture, this path exercises Linear's real headers, exact body bytes, signing secret, timeout, and retry behavior. That makes it useful for finding middleware and acknowledgement bugs before deployment.

1. Build a raw-body-safe endpoint

This Express example captures the body as a Buffer. It verifies the request before parsing JSON, checks Linear's recommended timestamp window, reserves a durable idempotency key, and queues work before acknowledging the delivery.

import express from "express";
import crypto from "node:crypto";

const app = express();
const secret = process.env.LINEAR_WEBHOOK_SECRET;

app.post(
  "/webhooks/linear",
  express.raw({ type: "application/json", limit: "1mb" }),
  async (req, res) => {
    const rawBody = req.body;
    const signature = req.get("linear-signature");

    if (!verifyLinearSignature(signature, rawBody, secret)) {
      return res.sendStatus(401);
    }

    let payload;
    try {
      payload = JSON.parse(rawBody.toString("utf8"));
    } catch {
      return res.sendStatus(400);
    }

    if (!Number.isFinite(payload.webhookTimestamp) ||
        Math.abs(Date.now() - payload.webhookTimestamp) > 60_000) {
      return res.sendStatus(401);
    }

    const deliveryId = req.get("linear-delivery") || payload.webhookId;
    if (!deliveryId) return res.sendStatus(400);

    try {
      await recordAndEnqueueOnce(deliveryId, payload);
      return res.sendStatus(200);
    } catch (error) {
      console.error("Linear webhook persistence failed", error);
      return res.sendStatus(500);
    }
  }
);

app.use(express.json());
app.listen(3000);

Mount any general JSON parser after this route. If express.json() consumes the body first, parsing and re-serializing it can change insignificant whitespace or escaping and therefore change the HMAC. Linear's official SDK offers a typed LinearWebhookClient as an alternative; its webhook SDK guide gives handlers for supported server frameworks and carries the same warning about preserving the raw body.

2. Expose the local port over HTTPS

Start the application and confirm it listens on the expected interface and port. In another terminal, run:

npx portpreview 3000

PortPreview prints a public HTTPS origin. Add the exact route path to form the endpoint Linear will call:

https://example.portpreview.dev/webhooks/linear

Keep the tunnel running throughout the test. If its public URL changes, update the configured Linear webhook. A browser GET is not a complete test because the receiver intentionally accepts POST only. You can make an unsigned POST to verify forwarding; receiving 401 confirms that the tunnel and route work while authentication correctly rejects the request.

3. Create the webhook in Linear

For a workspace webhook, open Linear Settings, find the API settings, choose New webhook, provide a useful label and the public HTTPS URL, then select the relevant teams and resource types exposed by the current UI. Linear documents that webhooks are organization-specific and can cover all public teams or a single team. Only workspace administrators, or OAuth applications with the admin scope, can create or read webhooks.

For a multi-workspace OAuth integration, configure webhooks in the OAuth application's settings instead. Linear creates the appropriate webhook when an organization authorizes that application. The event categories and authorization experience differ from a manually created workspace webhook, so do not assume the two setup paths are interchangeable.

Open the webhook's detail page and copy its signing secret into LINEAR_WEBHOOK_SECRET. Store it in a local secret manager or uncommitted environment file. It is not an API key and should never be embedded in client-side code. Then trigger an event covered by the subscription, such as creating or updating a test issue in an included team.

Verify Linear-Signature over the exact bytes

Linear sends Linear-Signature as a hex-encoded HMAC-SHA256 signature of the raw body, keyed with that webhook's signing secret. The receiver should decode both the expected and supplied hex values to equal-length buffers and compare them in constant time:

function verifyLinearSignature(signature, rawBody, secret) {
  if (!secret || typeof signature !== "string" ||
      !/^[0-9a-f]{64}$/i.test(signature)) {
    return false;
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest();
  const actual = Buffer.from(signature, "hex");

  return actual.length === expected.length &&
    crypto.timingSafeEqual(actual, expected);
}

Do not HMAC JSON.stringify(payload). The semantic object may be identical while its bytes differ from the body Linear signed. Also do not compare ordinary strings with === in production authentication code. A constant-time comparison avoids leaking how much of a candidate signature matched. The signature verification guide explains the raw-body requirement across frameworks.

Enforce timestamp-based replay protection

After the HMAC passes, parse the body and inspect webhookTimestamp, a Unix timestamp in milliseconds. Linear recommends verifying that it is within one minute of the receiver's clock. The symmetric check in the example rejects both old captures and implausibly future-dated messages. Keep the host clock synchronized; otherwise legitimate deliveries can fail this narrow window.

The headers also include Linear-Timestamp, but Linear's securing-webhooks example checks webhookTimestamp in the parsed body after signature validation. Following the signed payload field binds the replay check to the body authenticated by the HMAC. Never trust or parse fields into side effects before the signature succeeds.

Understand the payload and delivery identifiers

Data-change payloads include action, type, actor, createdAt, data, url, webhookTimestamp, and, for updates, updatedFrom. The exact data shape reflects the corresponding Linear GraphQL entity. Handle only known combinations of type and action, tolerate additive fields, and quarantine malformed or unsupported events rather than guessing their meaning.

Linear-Delivery is documented as a UUID that uniquely identifies the payload. Payloads also include webhookId. Prefer Linear-Delivery as the request-level idempotency key and retain webhookId for correlation; the fallback in the sample is useful only if your tested event contract supplies it. Enforce uniqueness in durable storage, not an in-memory set that disappears on restart.

Acknowledge quickly and process idempotently

Linear requires the consumer to return HTTP 200. A delivery is considered failed if the server is unavailable, takes longer than five seconds, or responds with a non-200 status. Linear retries a failed push at most three times, after approximately one minute, one hour, and six hours. Continued failures can cause the webhook to be disabled, requiring manual re-enablement.

The robust pattern is verify, validate, reserve the delivery ID, enqueue, and return 200. A worker performs slower actions such as updating a database, calling CI, or synchronizing another issue tracker. Make reserving the delivery ID and creating the job one atomic transaction. If the same delivery is retried, the unique constraint should prevent duplicate work while the endpoint still returns 200.

If the queue or database is unavailable before durable acceptance, return 500 so Linear can retry. Do not return 200 and then rely on an in-memory promise: a crash would lose an event Linear believes was accepted. Conversely, do not wait for every downstream API call inside the five-second request budget. See webhook retry and idempotency patterns for transaction and worker designs.

Test the complete local flow

  1. Start the local server and PortPreview tunnel.
  2. Register the full HTTPS route and save the signing secret securely.
  3. Create or update a test object matching the chosen resource type and team scope.
  4. Confirm the request has Linear-Event, Linear-Delivery, Linear-Signature, and JSON content type headers.
  5. Confirm signature and timestamp checks pass without logging their secrets.
  6. Repeat the same delivery fixture locally and verify that only one job or state transition is created.
  7. Temporarily return 500 in a safe test workspace only if you intentionally want to observe retry handling; restore success promptly to avoid disabling the webhook.

Use synthetic unsigned requests only to test rejection paths. A payload copied from logs cannot be given a valid signature unless it is paired with its exact original bytes and header, and sensitive issue text should not be copied into shared tooling.

Troubleshoot Linear webhook failures

  • No delivery appears: verify the tunnel is active, the full route is saved, the webhook is enabled, and the changed resource belongs to the configured team and category.
  • 404 or 405: compare the configured path with the Express POST route. Confirm the public URL reaches the same port as the application.
  • Every signature is invalid: capture a raw Buffer before body parsing, use the signing secret from this webhook's detail page, and decode the supplied signature as hexadecimal.
  • Legitimate requests fail the timestamp check: synchronize the machine clock and confirm webhookTimestamp is treated as milliseconds, not seconds.
  • Linear retries or disables the webhook: return exactly 200 within five seconds after durable enqueueing. Inspect route exceptions, proxy errors, and persistence latency.
  • Work runs twice: add a database uniqueness constraint on Linear-Delivery; checking first and inserting later is race-prone.

The local webhook debugging guide provides a layer-by-layer checklist when the failure is not obviously Linear-specific.

Security checklist for local and production endpoints

  • Require HTTPS and verify the HMAC before trusting any payload field.
  • Reject missing, malformed, or unequal signatures with a bounded request body size.
  • Apply Linear's recommended one-minute freshness check after signature validation.
  • Keep signing secrets server-side, separate by environment and webhook, and rotate exposed values.
  • Redact issue titles, comments, user data, signatures, and secrets from logs.
  • Use least-privilege team and resource scopes; do not subscribe to data the integration does not need.
  • Use durable idempotency and queues, with monitoring for repeated failures and disabled webhooks.
  • Shut down temporary tunnels and remove stale test URLs after the session.

Linear also publishes source IP addresses as an optional additional control, while warning that the list may change. Treat allowlisting as defense in depth, not a replacement for HMAC and freshness verification. Review the live official list before implementing network rules. The localhost tunnel security guide covers safe exposure practices for development endpoints.

Once these controls are in place, a localhost test reproduces the important production boundary: a real Linear event crosses public HTTPS, is authenticated over its original bytes, survives retries without duplicate effects, and is acknowledged inside the provider's deadline.

Frequently asked questions

How do I test a Linear webhook on localhost?
Run a local POST endpoint, expose its port with npx portpreview PORT, add the resulting HTTPS route in Linear's API settings, select the relevant resources, and trigger a matching workspace change.
How do I verify Linear-Signature?
Compute HMAC-SHA256 over the exact raw request body with the webhook signing secret, decode the Linear-Signature header from hex, and compare equal-length buffers in constant time.
Why is Linear retrying or disabling my webhook?
Linear treats an unavailable server, a response taking over five seconds, or any non-200 response as failure. It retries up to three times after one minute, one hour, and six hours; continued failures may disable the webhook.
Which value should I use for Linear webhook idempotency?
Use the Linear-Delivery UUID as a durable request-level idempotency key and enforce it with a database uniqueness constraint. Keep webhookId for correlation when the event contract includes it.