Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.1.0] - 2026-03-26

### Changed

- Webhook handling now uses `@octokit/webhooks` `verify()` and `receive()` separately for clearer control flow.
- HTTP responses: `400` for invalid JSON body; `500` when a registered handler throws or rejects; `401` remains for invalid signature.

### Documentation

- Documented why runtime payload schema validation is not included, and how consumers can validate in their own handlers if desired.
- Expanded the **Responses** table in the README.

[1.1.0]: https://github.com/nbaglivo/nextjs-github-webhooks/compare/v1.0.2...v1.1.0
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ Add your GitHub webhook secret to `.env.local`:
GITHUB_WEBHOOK_SECRET=your_webhook_secret_here
```

## Payload validation

This library verifies the **HMAC signature** (`X-Hub-Signature-256`) against the raw request body so only requests that match your GitHub webhook secret are accepted. It does **not** run runtime schema validation on the parsed JSON (for example with Zod or similar).

**Why:** A schema layer would add dependencies and ongoing maintenance beyond what `@octokit/webhooks` already provides as TypeScript types. For signed webhooks, the practical trust boundary is authenticity: the payload is what GitHub sent for that delivery.

**If you want to be extra defensive**—for example strict checks before branching on nested fields, compliance requirements, or guarding against unexpected shapes—validate `ctx.payload` inside your own handlers using whatever fits your project (Zod, manual guards, etc.). That stays optional and avoids pulling validation libraries into every consumer.

## API

### `createGitHubWebhookHandler(options)`
Expand Down Expand Up @@ -97,11 +105,12 @@ handlers: {

## Responses

| Status | Condition |
| ------ | --------------------------------- |
| `400` | Missing headers (`x-hub-signature-256`, `x-github-event`, or `x-github-delivery`) |
| `401` | Invalid signature |
| `200` | Webhook processed successfully |
| Status | Condition |
| ------ | --------- |
| `400` | Missing required headers (`x-hub-signature-256`, `x-github-event`, or `x-github-delivery`), or body is not valid JSON |
| `401` | Invalid signature |
| `500` | A registered handler threw or rejected |
| `200` | Webhook processed successfully |

## License

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nextjs-github-webhooks",
"version": "1.0.2",
"version": "1.1.0",
"description": "Lightweight integration for GitHub webhooks in Next.js",
"license": "MIT",
"author": "Nicolas Baglivo <nicolas.baglivo@gmail.com> (https://nbaglivo.dev)",
Expand All @@ -16,7 +16,8 @@
"files": [
"dist",
"README.md",
"LICENSE"
"LICENSE",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc",
Expand Down
61 changes: 44 additions & 17 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createGitHubWebhookHandler } from "./index";

const { mockVerifyAndReceive, mockHandlers } = vi.hoisted(() => ({
mockVerifyAndReceive: vi.fn(),
const { mockVerify, mockReceive, mockHandlers } = vi.hoisted(() => ({
mockVerify: vi.fn(),
mockReceive: vi.fn(),
mockHandlers: {} as Record<string, (evt: { id: string; payload: unknown }) => void>,
}));

Expand All @@ -11,7 +12,8 @@ vi.mock("@octokit/webhooks", () => ({
on: vi.fn((event: string, fn: (evt: { id: string; payload: unknown }) => void) => {
mockHandlers[event] = fn;
}),
verifyAndReceive: mockVerifyAndReceive,
verify: mockVerify,
receive: mockReceive,
})),
}));

Expand Down Expand Up @@ -41,7 +43,8 @@ describe("createGitHubWebhookHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.keys(mockHandlers).forEach((k) => delete mockHandlers[k]);
mockVerifyAndReceive.mockResolvedValue(undefined);
mockVerify.mockResolvedValue(true);
mockReceive.mockResolvedValue(undefined);
});

it("returns 400 when x-hub-signature-256 header is missing", async () => {
Expand All @@ -56,7 +59,7 @@ describe("createGitHubWebhookHandler", () => {

expect(res.status).toBe(400);
expect(data).toEqual({ error: "Missing headers" });
expect(mockVerifyAndReceive).not.toHaveBeenCalled();
expect(mockVerify).not.toHaveBeenCalled();
});

it("returns 400 when x-github-event header is missing", async () => {
Expand All @@ -71,7 +74,7 @@ describe("createGitHubWebhookHandler", () => {

expect(res.status).toBe(400);
expect(data).toEqual({ error: "Missing headers" });
expect(mockVerifyAndReceive).not.toHaveBeenCalled();
expect(mockVerify).not.toHaveBeenCalled();
});

it("returns 400 when x-github-delivery header is missing", async () => {
Expand All @@ -86,11 +89,11 @@ describe("createGitHubWebhookHandler", () => {

expect(res.status).toBe(400);
expect(data).toEqual({ error: "Missing headers" });
expect(mockVerifyAndReceive).not.toHaveBeenCalled();
expect(mockVerify).not.toHaveBeenCalled();
});

it("returns 401 when verifyAndReceive throws (invalid signature)", async () => {
mockVerifyAndReceive.mockRejectedValueOnce(new Error("Invalid signature"));
it("returns 401 when verify returns false (signature does not match)", async () => {
mockVerify.mockResolvedValueOnce(false);
const handler = createGitHubWebhookHandler({
secret: "test-secret",
handlers: {},
Expand All @@ -101,12 +104,36 @@ describe("createGitHubWebhookHandler", () => {

expect(res.status).toBe(401);
expect(data).toEqual({ error: "Invalid signature" });
expect(mockVerifyAndReceive).toHaveBeenCalledWith({
id: "delivery-123",
name: "push",
signature: "sha256=abc",
payload: '{"ref":"refs/heads/main"}',
expect(mockVerify).toHaveBeenCalledWith('{"ref":"refs/heads/main"}', "sha256=abc");
expect(mockReceive).not.toHaveBeenCalled();
});

it("returns 400 when body is not valid JSON", async () => {
const handler = createGitHubWebhookHandler({
secret: "test-secret",
handlers: {},
});

const res = await handler(createRequest({ body: "{not-json" }));
const data = await res.json();

expect(res.status).toBe(400);
expect(data).toEqual({ error: "Invalid JSON payload" });
expect(mockReceive).not.toHaveBeenCalled();
});

it("returns 500 when receive rejects because a handler failed", async () => {
mockReceive.mockRejectedValueOnce(new Error("database unavailable"));
const handler = createGitHubWebhookHandler({
secret: "test-secret",
handlers: {},
});

const res = await handler(createRequest());
const data = await res.json();

expect(res.status).toBe(500);
expect(data).toEqual({ error: "Webhook handler failed" });
});

it("returns 200 with received: true when signature is valid", async () => {
Expand All @@ -124,9 +151,9 @@ describe("createGitHubWebhookHandler", () => {

it("invokes the registered handler with id and payload when event is received", async () => {
const pushHandler = vi.fn().mockResolvedValue(undefined);
mockVerifyAndReceive.mockImplementation(async (opts: { id: string; name: string; payload: string }) => {
const handler = mockHandlers[opts.name];
if (handler) await handler({ id: opts.id, payload: JSON.parse(opts.payload) });
mockReceive.mockImplementation(async (evt: { id: string; name: string; payload: unknown }) => {
const fn = mockHandlers[evt.name];
if (fn) await fn({ id: evt.id, payload: evt.payload });
});

const handler = createGitHubWebhookHandler({
Expand Down
27 changes: 21 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,32 @@ export function createGitHubWebhookHandler(options: Options) {

const body = await req.text();

// https://github.com/octokit/webhooks.js#webhooksverify
if (!(await webhooks.verify(body, signature))) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}

let payload: unknown;
try {
await webhooks.verifyAndReceive({
payload = JSON.parse(body);
} catch {
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 });
}

// https://github.com/octokit/webhooks.js#webhooksreceive
try {
await webhooks.receive({
id: delivery,
name: event,
signature,
payload: body,
name: event as any,
payload: payload as any,
});
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
return NextResponse.json(
{ error: "Webhook handler failed" },
{ status: 500 }
);
}

return NextResponse.json({ received: true });
}
};
}
Loading