npm package: @northly/webhook-verify
Description: Verify webhook signatures from the Northly OKR Tracker
Latest version: 1.0.0

# @northly/webhook-verify

Lightweight Node.js SDK for verifying webhook signatures from the [Northly OKR Tracker](https://northly.app). Zero runtime dependencies -- uses only the built-in Node.js `crypto` module.

## Installation

```bash
npm install @northly/webhook-verify
```

## Quick start

```ts
import { constructEvent, WebhookSignatureError } from "@northly/webhook-verify";

const secret = process.env.NORTHLY_WEBHOOK_SECRET; // starts with whsec_

try {
  const event = constructEvent(rawBody, signatureHeader, secret);
  console.log(event.type); // e.g. "objective.created"
  console.log(event.data); // event payload
} catch (err) {
  if (err instanceof WebhookSignatureError) {
    // Signature mismatch — reject the request
  }
}
```

## Headers sent by Northly

| Header | Description |
|---|---|
| `X-Webhook-Signature` | `sha256=<hex>` HMAC-SHA256 of the raw body |
| `X-Webhook-Event` | Event type, e.g. `objective.created` |
| `Content-Type` | `application/json` |

## Usage with Express.js

```ts
import express from "express";
import { constructEvent, WebhookSignatureError } from "@northly/webhook-verify";

const app = express();

// IMPORTANT: use raw body — do not parse JSON before verification
app.post(
  "/webhooks/northly",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-webhook-signature"] as string;

    try {
      const event = constructEvent(req.body, signature, process.env.NORTHLY_WEBHOOK_SECRET!);

      switch (event.type) {
        case "objective.created":
          // handle objective created
          break;
        case "checkin.created":
          // handle check-in created
          break;
        default:
          console.log(`Unhandled event type: ${event.type}`);
      }

      res.status(200).json({ received: true });
    } catch (err) {
      if (err instanceof WebhookSignatureError) {
        return res.status(401).json({ error: "Invalid signature" });
      }
      res.status(500).json({ error: "Internal server error" });
    }
  }
);
```

## Usage with Next.js API route (App Router)

```ts
// app/api/webhooks/northly/route.ts
import { constructEvent, WebhookSignatureError } from "@northly/webhook-verify";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const signature = req.headers.get("x-webhook-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 401 });
  }

  try {
    const event = constructEvent(rawBody, signature, process.env.NORTHLY_WEBHOOK_SECRET!);

    switch (event.type) {
      case "objective.created":
        // handle objective created
        break;
      case "checkin.created":
        // handle check-in created
        break;
    }

    return NextResponse.json({ received: true });
  } catch (err) {
    if (err instanceof WebhookSignatureError) {
      return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
    }
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}
```

## API reference

### `verifyWebhookSignature(payload, signature, secret): boolean`

Verify the HMAC-SHA256 signature of a webhook payload using constant-time comparison.

| Parameter | Type | Description |
|---|---|---|
| `payload` | `string \| Buffer` | The raw request body |
| `signature` | `string` | The `X-Webhook-Signature` header value (with or without `sha256=` prefix) |
| `secret` | `string` | Your webhook signing secret |

Returns `true` if valid, `false` otherwise. Never throws.

### `constructEvent(payload, signature, secret): WebhookEvent`

Verify the signature and parse the payload into a typed event object. Throws `WebhookSignatureError` if the signature is invalid or the payload cannot be parsed.

| Parameter | Type | Description |
|---|---|---|
| `payload` | `string \| Buffer` | The raw request body |
| `signature` | `string` | The `X-Webhook-Signature` header value |
| `secret` | `string` | Your webhook signing secret |

### `WebhookEvent`

```ts
interface WebhookEvent {
  type: string;       // Event name, e.g. "objective.created"
  created_at: string; // ISO-8601 timestamp
  data: Record<string, unknown>; // Event payload
}
```

### `WebhookSignatureError`

Custom error class thrown by `constructEvent` when signature verification fails.

## Signature algorithm

Northly computes signatures as follows:

1. Take the raw JSON request body as a UTF-8 string.
2. Compute `HMAC-SHA256(body, secret)` using the webhook signing secret.
3. Encode the result as a lowercase hex string.
4. Send it in the `X-Webhook-Signature` header prefixed with `sha256=`.

This package reproduces that process and compares using `crypto.timingSafeEqual` to prevent timing attacks.