Horizon

Test Linear webhooks locally

Receive Linear webhook events on localhost with a Horizon tunnel, and verify the Linear-Signature header with the Linear SDK.

Receive Linear events on your laptop while you build, with a URL Linear can reach.

Horizon has no Linear integration. Linear sends webhooks to a public URL, and Horizon provides that URL.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • A Next.js app that uses the App Router and runs on port 3000
  • A Linear workspace where you are an admin. Only workspace admins, or OAuth applications with the admin scope, can create webhooks.

Start your app

Linear signs the raw request body with an HMAC (a keyed hash), using SHA-256 and the webhook's signing secret. It sends the hex digest in the Linear-Signature header. Each payload also has a webhookTimestamp field in Unix milliseconds. Linear recommends you reject a delivery that is more than about a minute old, to guard against replay attacks.

The @linear/sdk package does both checks in LinearWebhookClient.verify. Install it:

npm install @linear/sdk

The handler reads the raw bytes, passes them with the header and the timestamp to verify, and returns 401 if verification fails.

app/api/webhooks/linear/route.ts
import {
  LINEAR_WEBHOOK_SIGNATURE_HEADER,
  LINEAR_WEBHOOK_TS_FIELD,
  LinearWebhookClient,
} from "@linear/sdk/webhooks";

export async function POST(request: Request) {
  const secret = process.env.LINEAR_WEBHOOK_SECRET;
  if (!secret) {
    return new Response("Missing LINEAR_WEBHOOK_SECRET", { status: 500 });
  }

  const rawBody = Buffer.from(await request.arrayBuffer());
  const payload = JSON.parse(rawBody.toString());
  const webhookClient = new LinearWebhookClient(secret);

  try {
    webhookClient.verify(
      rawBody,
      request.headers.get(LINEAR_WEBHOOK_SIGNATURE_HEADER) ?? "",
      payload[LINEAR_WEBHOOK_TS_FIELD],
    );
  } catch {
    return new Response("Invalid signature", { status: 401 });
  }

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

  return new Response("ok", { status: 200 });
}

Linear expects HTTP 200 within 5 seconds. Keep the handler fast.

You get the signing secret in a later step. Add it to .env.local then.

.env.local
LINEAR_WEBHOOK_SECRET=replace-with-your-signing-secret

Start the app on port 3000.

npm run dev

Start a tunnel

Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your Linear webhook would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.

hrzn tunnel http://localhost:3000 -s my-linear-app
Output
HORIZON: Tunnel connected
  URL          https://my-linear-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-linear-app

Your public URL is https://my-linear-app.hrzn.run. Keep this terminal open.

Add the webhook in Linear

  1. In Linear, open Settings, then API.
  2. Select New webhook.
  3. Set URL to https://my-linear-app.hrzn.run/api/webhooks/linear.
  4. Set Label to a name that describes the webhook.
  5. Choose the resource types you want, such as issues or comments.
  6. Save the webhook, then open its detail page. Copy the signing secret into LINEAR_WEBHOOK_SECRET in .env.local.

Restart npm run dev so Next.js loads the new variable.

Trigger an event

Linear documents no test button for webhooks. Cause a real event instead. Create an issue or add a comment in your workspace, in a resource type the webhook covers.

Check it works

In the terminal that runs hrzn, you see one line for the delivery:

Output
  POST    200  /api/webhooks/linear

Your app terminal prints a line such as:

Output
Received Linear event: Issue create

If the line shows [401], see Troubleshooting.

Troubleshooting

Verification fails

  • Check that LINEAR_WEBHOOK_SECRET is the signing secret on this webhook's detail page, with no extra spaces or newline.
  • Pass the raw bytes to verify. Linear signs the exact raw body, so parsing it first breaks the check.
  • Check your machine's clock. The timestamp check rejects a delivery that is about a minute old.
  • Restart npm run dev after you edit .env.local.

Linear retries the same event

Linear retries a failed delivery up to 3 times, after 1 minute, 1 hour, and 6 hours. A failure is a non-200 response, or no response within 5 seconds. Use the Linear-Delivery header, a unique UUID for each payload, to spot duplicates.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-linear-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.

Nothing reaches your app

  • Check that the Horizon terminal is still running. If its last line is Connection lost. Reconnecting…, wait for Reconnected.
  • Check that URL ends with /api/webhooks/linear.
  • Check that the webhook covers the resource type you change.

Next steps

On this page