Skip to content

Receive a GitHub webhook

Receive GitHub issue events, verify the signature, and save opened issues in SQLite. A database uniqueness constraint prevents repeated deliveries from creating another record. Test the endpoint with signed local requests before connecting a repository on GitHub.

Before you start

Use the Next.js and SQLite app from the quickstart. Run commands from its root with Bun. The tests need no GitHub account or network connection. Connecting a real webhook requires permission to configure one for your repository and an HTTPS URL GitHub can reach.

The result is a durable record of each opened issue, not a synchronization of later edits or a new issues UI. Additional effects such as sending mail should be recorded through an outbox in the same transaction as the issue.

Install and configure the verifier

bash
bun add @beignet/webhooks-github

Add the configuration below to .env.example and put your actual values in .env.local. Generate the secret with openssl rand -hex 32. Use the numeric repository ID from GitHub's repository API response, not the repository name:

dotenv
GITHUB_WEBHOOK_SECRET=replace-with-a-random-webhook-secret
APP_GITHUB_REPOSITORY_ID=123456789

The APP_ prefix keeps this setting separate from GitHub Actions' built-in GITHUB_REPOSITORY_ID variable.

Validate both entries in the existing server environment schema:

lib/env.ts

Use the same secret in your app and the GitHub webhook settings. The verifier checks the signature over the original request body before parsing JSON; follow GitHub's signature guidance when rotating or configuring it. This verifier belongs at the route boundary and does not install a lifecycle provider.

Define the event and application input

Keep GitHub's payload schema in the feature, separate from the application's input schema:

typescript
// features/github/schemas.ts
import { z } from "zod";

export const GitHubIssuePayloadSchema = z.object({
  action: z.string(),
  repository: z.object({ id: z.number().int().positive() }),
  issue: z.object({
    id: z.number().int().positive(),
    number: z.number().int().positive(),
    title: z.string(),
  }),
});
export const OpenedIssueInputSchema = z.object({
  deliveryId: z.string().min(1),
  repositoryId: z.number().int().positive(),
  issueId: z.number().int().positive(),
  issueNumber: z.number().int().positive(),
  title: z.string(),
});
export type OpenedIssueInput = z.infer<typeof OpenedIssueInputSchema>;
typescript
// features/github/webhooks.ts
import { defineWebhook } from "@beignet/core/webhooks";
import { z } from "zod";
import { GitHubIssuePayloadSchema } from "./schemas";

export const githubWebhook = defineWebhook("github.issues", {
  provider: "github",
  events: {
    ping: z.object({ zen: z.string() }),
    issues: GitHubIssuePayloadSchema,
  },
});

The catalog accepts GitHub's setup ping and issue events. Other event types are rejected. The route will acknowledge issue actions it does not process, such as edited or closed.

Persist the opened issue once

Add the table and export it from the existing schema index:

typescript
// infra/db/schema/github-issues.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const githubIssues = sqliteTable("github_issues", {
  issueId: integer("issue_id").primaryKey(),
  deliveryId: text("delivery_id").notNull().unique(),
  repositoryId: integer("repository_id").notNull(),
  issueNumber: integer("issue_number").notNull(),
  title: text("title").notNull(),
});
infra/db/schema/index.ts

The starter's reset script lists the tables it drops. Add github_issues so a development database reset can recreate the table from its migration:

infra/db/reset.ts
typescript
// features/github/ports.ts
import type { OpenedIssueInput } from "./schemas";

export type GitHubIssueRepository = {
  recordOpenedIssue(input: OpenedIssueInput): Promise<{ created: boolean }>;
};
typescript
// infra/github/drizzle-github-issue-repository.ts
import "@beignet/core/server-only";
import type { DrizzleSqliteDatabase } from "@beignet/provider-db-drizzle/sqlite";
import type { GitHubIssueRepository } from "@/features/github/ports";
import * as schema from "@/infra/db/schema";

export function createDrizzleGitHubIssueRepository(
  db: DrizzleSqliteDatabase<typeof schema>,
): GitHubIssueRepository {
  return {
    async recordOpenedIssue(input) {
      const rows = await db
        .insert(schema.githubIssues)
        .values(input)
        .onConflictDoNothing({ target: schema.githubIssues.issueId })
        .returning({ id: schema.githubIssues.issueId });
      return { created: rows.length === 1 };
    },
  };
}

One database insert records the issue and decides whether it is new. There is no separate "check, then insert" race. The stable issue ID makes the opened-issue operation safe even if the same payload arrives with a different delivery ID. The stored delivery ID identifies the first accepted delivery for investigation. This does not deduplicate arbitrary future workflows or provide exactly-once delivery for external side effects.

Add the repository to the app ports, transaction repository factory, database provider, and deferred port list:

ports/index.ts
infra/db/repositories.ts
infra/db/provider.ts
infra/port-wiring.ts
typescript
// features/github/use-cases.ts
import { z } from "zod";
import { useCase } from "@/lib/use-case";
import { OpenedIssueInputSchema } from "./schemas";

export const recordOpenedIssueUseCase = useCase
  .command("github.record-opened-issue")
  .input(OpenedIssueInputSchema)
  .output(z.object({ created: z.boolean() }))
  .run(({ ctx, input }) => ctx.ports.githubIssues.recordOpenedIssue(input));

Expose the verified route

Create the route factory below so production and tests use the same verifier, event catalog, repository restriction, and handler:

typescript
// server/github-webhook.ts
import {
  createWebhookRoute,
  type CreateWebhookRouteOptions,
} from "@beignet/next";
import { createGitHubWebhookVerifier } from "@beignet/webhooks-github";
import type { AppContext } from "@/app-context";
import { recordOpenedIssueUseCase } from "@/features/github/use-cases";
import { githubWebhook } from "@/features/github/webhooks";
import { errors } from "@/features/shared/errors";

export function createGitHubIssueRoute(options: {
  server: CreateWebhookRouteOptions<AppContext, typeof githubWebhook>["server"];
  secret: string;
  repositoryId: number;
}) {
  const verifier = createGitHubWebhookVerifier({ secret: options.secret });
  return createWebhookRoute({
    server: options.server,
    webhook: githubWebhook,
    verify: ({ input }) => verifier.verify(input),
    async handle({ ctx, event }) {
      if (event.type === "ping") return { body: { received: true } };
      if (event.payload.repository.id !== options.repositoryId) {
        return {
          status: errors.Forbidden.status,
          body: {
            code: errors.Forbidden.code,
            message: errors.Forbidden.message,
          },
        };
      }
      if (event.payload.action !== "opened") {
        return { body: { received: true, ignored: true } };
      }
      const result = await recordOpenedIssueUseCase.run({
        ctx,
        input: {
          deliveryId: event.id,
          repositoryId: event.payload.repository.id,
          issueId: event.payload.issue.id,
          issueNumber: event.payload.issue.number,
          title: event.payload.issue.title,
        },
      });
      return { body: { received: true, created: result.created } };
    },
  });
}
typescript
// app/api/webhooks/github/route.ts
import { env } from "@/lib/env";
import { getServer } from "@/server";
import { createGitHubIssueRoute } from "@/server/github-webhook";

export const runtime = "nodejs";
export const { POST } = createGitHubIssueRoute({
  server: getServer,
  secret: env.GITHUB_WEBHOOK_SECRET,
  repositoryId: env.APP_GITHUB_REPOSITORY_ID,
});

GitHub authenticates through the webhook signature, not a browser session. If you have added global session requirements or middleware, configure this endpoint to use signature verification while retaining those requirements for user-facing routes. Keep body parsing inside createWebhookRoute so signature verification receives the original bytes.

Generate the migration and test signed requests

bash
bun --env-file=.env.local run beignet db generate
bun --env-file=.env.local run beignet db migrate
bun --env-file=.env.local run beignet db status

These commands explicitly load .env.local for tools running under Node. Review and commit the migration. Add this test using the real SQLite adapter and the same route factory as production:

typescript
// features/github/tests/webhook.test.ts
import { createHmac } from "node:crypto";
import { createTestContext } from "@beignet/core/testing";
import type { AppContext } from "@/app-context";
import { githubIssues } from "@/infra/db/schema";
import { createTestDatabase } from "@/infra/db/test-database";
import { expect, test } from "@/lib/beignet-test";
import { createGitHubIssueRoute } from "@/server/github-webhook";

test("verifies GitHub deliveries and persists an opened issue once", async () => {
  const database = await createTestDatabase();
  const fixture = createTestContext<AppContext>()({
    ports: { githubIssues: database.repositories.githubIssues },
    ambient: false,
  });
  const secret = "local-webhook-test-secret";
  const { POST } = createGitHubIssueRoute({
    server: { createRequestContext: async () => fixture.ctx },
    secret,
    repositoryId: 123,
  });
  const payload = {
    action: "opened",
    repository: { id: 123 },
    issue: { id: 456, number: 1, title: "Fix the build" },
  };
  function request(
    body: unknown,
    options: {
      event?: string;
      delivery?: string;
      key?: string;
      tamper?: boolean;
    } = {},
  ) {
    const raw = JSON.stringify(body);
    const signature = createHmac("sha256", options.key ?? secret)
      .update(raw)
      .digest("hex");
    return new Request("http://localhost/api/webhooks/github", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-github-event": options.event ?? "issues",
        "x-github-delivery": options.delivery ?? "delivery-1",
        "x-hub-signature-256": `sha256=${signature}`,
      },
      body: options.tamper ? `${raw} ` : raw,
    });
  }
  try {
    expect((await POST(request(payload, { key: "wrong-secret" }))).status).toBe(
      400,
    );
    expect((await POST(request(payload, { tamper: true }))).status).toBe(400);
    expect((await POST(request({ action: "opened" }))).status).toBe(400);
    expect((await POST(request(payload, { event: "push" }))).status).toBe(400);
    expect(
      (await POST(request({ ...payload, repository: { id: 999 } }))).status,
    ).toBe(403);
    const ping = await POST(
      request({ zen: "Keep it small" }, { event: "ping" }),
    );
    expect(ping.status).toBe(200);
    const ignored = await POST(request({ ...payload, action: "closed" }));
    expect(await ignored.json()).toMatchObject({ ignored: true });
    expect(await database.db.select().from(githubIssues)).toHaveLength(0);

    const first = await POST(request(payload));
    expect(first.status).toBe(200);
    expect(await first.json()).toEqual({ received: true, created: true });
    const duplicate = await POST(request(payload));
    expect(duplicate.status).toBe(200);
    expect(await duplicate.json()).toEqual({ received: true, created: false });
    const repeated = await POST(request(payload, { delivery: "delivery-2" }));
    expect(await repeated.json()).toMatchObject({ created: false });
    const rows = await database.db.select().from(githubIssues);
    expect(rows).toHaveLength(1);
    expect(rows[0]).toMatchObject({ issueId: 456, title: "Fix the build" });
  } finally {
    await fixture.dispose();
    await database.close();
  }
});
bash
bun run format
bun --env-file=.env.local run test
bun --env-file=.env.local run beignet check

Expect the signed valid request to create one row, duplicates to return 200 without another row, and invalid signatures or payloads to return 400 before persistence. The tests use a local secret and a temporary database.

Connect GitHub and verify redelivery

Deploy the endpoint and migration with GITHUB_WEBHOOK_SECRET and APP_GITHUB_REPOSITORY_ID configured. For local development, use an HTTPS forwarding service you trust to reach your running app. GitHub cannot reach your localhost directly. Follow GitHub's webhook setup:

  1. Set the payload URL to https://your-app.example/api/webhooks/github.
  2. Select JSON content, enter the matching secret, and keep SSL verification enabled.
  3. Subscribe to Issues events and activate the webhook.
  4. Check that the initial ping receives HTTP 200.
  5. Open an issue. Its delivery should return { "received": true, "created": true }.
  6. Redeliver that event from GitHub's delivery history. Expect HTTP 200 with created: false and still one row in github_issues.

GitHub expects a response within 10 seconds. This handler waits for a single database insert; if you add slow work, persist it durably for later execution before acknowledging. See GitHub webhook practices.

GitHub does not automatically redeliver failed deliveries. Review failures and redeliver them manually or configure a recovery process. A 500 response records a failure; it does not schedule a retry. See handling failed deliveries.

Troubleshooting

SymptomCheck
Verification returns 400Match the secrets and JSON content type; preserve the raw body. Check the event type and payload schema too.
A valid event returns 403Match the numeric repository ID to APP_GITHUB_REPOSITORY_ID.
Delivery returns 401 before verificationInspect app middleware or global session hooks that may require browser authentication.
Delivery returns 500Inspect server logs and migration status. Do not mark a failed write as processed; retry after fixing the cause.
An edited issue does not update the rowThis workflow records only opened; define update behavior separately before handling other actions.

Work with your agent

text
Implement the github-webhook guide in this Beignet SQLite app. Read local
instructions and existing integrations. Use the GitHub verifier at the route
boundary, validate payloads, restrict the repository, and record opened issues
through a use case and repository port. Use database uniqueness for duplicate
deliveries. Generate and review the migration. Test bad signatures, tampered
bodies, bad payloads, unknown events, wrong repositories, ping, ignored actions,
success, and redelivery. Run format, tests, and beignet check. Keep secrets out
of Git and explain how to inspect and redeliver failed events on GitHub.