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
adminscope, 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/sdkThe handler reads the raw bytes, passes them with the header and the timestamp to verify, and returns 401 if verification fails.
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.
LINEAR_WEBHOOK_SECRET=replace-with-your-signing-secretStart the app on port 3000.
npm run devStart 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-appHORIZON: Tunnel connected
URL https://my-linear-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-linear-appYour public URL is https://my-linear-app.hrzn.run. Keep this terminal open.
Add the webhook in Linear
- In Linear, open Settings, then API.
- Select New webhook.
- Set URL to
https://my-linear-app.hrzn.run/api/webhooks/linear. - Set Label to a name that describes the webhook.
- Choose the resource types you want, such as issues or comments.
- Save the webhook, then open its detail page. Copy the signing secret into
LINEAR_WEBHOOK_SECRETin.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:
POST 200 /api/webhooks/linearYour app terminal prints a line such as:
Received Linear event: Issue createIf the line shows [401], see Troubleshooting.
Troubleshooting
Verification fails
- Check that
LINEAR_WEBHOOK_SECRETis 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 devafter 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 forReconnected. - Check that URL ends with
/api/webhooks/linear. - Check that the webhook covers the resource type you change.
Next steps
- Read Linear's webhooks reference.
- Read how the Linear SDK handles webhooks.