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
bun add @beignet/webhooks-githubAdd 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:
GITHUB_WEBHOOK_SECRET=replace-with-a-random-webhook-secret
APP_GITHUB_REPOSITORY_ID=123456789The APP_ prefix keeps this setting separate from GitHub Actions' built-in
GITHUB_REPOSITORY_ID variable.
Validate both entries in the existing server environment schema:
26 unmodified lines272829303132333426 unmodified lines .enum(["development", "test", "production"]) .default("development"), APP_URL: z.url().default("http://localhost:3000"), GITHUB_WEBHOOK_SECRET: z.string().min(32), APP_GITHUB_REPOSITORY_ID: z.coerce.number().int().positive(), DEVTOOLS_ENABLED: BooleanEnv.optional(), BETTER_AUTH_SECRET: isProductionRuntime ? BetterAuthSecretUse 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:
// 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>;// 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:
// 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(),
});70 unmodified lines717273747570 unmodified lines }, (table) => [index("todos_user_idx").on(table.userId, table.createdAt)],);
export { githubIssues } from "./github-issues";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:
14 unmodified lines151617181914 unmodified linesconst tables = [ "github_issues", "verification", "account", "session",// features/github/ports.ts
import type { OpenedIssueInput } from "./schemas";
export type GitHubIssueRepository = {
recordOpenedIssue(input: OpenedIssueInput): Promise<{ created: boolean }>;
};// 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:
7 unmodified lines8910111213141516171819205 unmodified lines2627282930317 unmodified lines} from "@beignet/core/ports";import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";import type { TodoRepository } from "@/features/todos/ports";import type { GitHubIssueRepository } from "@/features/github/ports";import type { AuthPort } from "./auth";
export type AppTransactionPorts = { idempotency: IdempotencyPort; todos: TodoRepository; githubIssues: GitHubIssueRepository;};
export type AppGate = BoundGate<[typeof todoPolicy]>;5 unmodified lines idempotency: IdempotencyPort; logger: LoggerPort; todos: TodoRepository; githubIssues: GitHubIssueRepository; uow: UnitOfWorkPort<AppTransactionPorts>;};1234562 unmodified lines91011121314import type { DrizzleSqliteDatabase } from "@beignet/provider-db-drizzle/sqlite";import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";import { createDrizzleGitHubIssueRepository } from "@/infra/github/drizzle-github-issue-repository";import type { AppTransactionPorts } from "@/ports";import type * as schema from "./schema";
2 unmodified lines): Omit<AppTransactionPorts, "idempotency"> { return { todos: createDrizzleTodoRepository(db), githubIssues: createDrizzleGitHubIssueRepository(db), };}25 unmodified lines262728292930313225 unmodified lines const repositories = createRepositories(dbPort.drizzle); const idempotency = createDrizzleSqliteIdempotencyPort(dbPort.drizzle);
const providedPorts: Pick<AppPorts, "idempotency" | "todos" | "uow"> = { const providedPorts: Pick<AppPorts, "idempotency" | "todos" | "uow" | "githubIssues"> = { ...repositories, idempotency, uow: createDrizzleSqliteUnitOfWork({25 unmodified lines26272829293025 unmodified lines errorReporter: createNoopErrorReporter(), gate, }, deferred: ["auth", "idempotency", "logger", "todos", "uow"], deferred: ["auth", "idempotency", "logger", "todos", "uow", "githubIssues"],});// 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:
// 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 } };
},
});
}// 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
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 statusThese 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:
// 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();
}
});bun run format
bun --env-file=.env.local run test
bun --env-file=.env.local run beignet checkExpect 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:
- Set the payload URL to
https://your-app.example/api/webhooks/github. - Select JSON content, enter the matching secret, and keep SSL verification enabled.
- Subscribe to Issues events and activate the webhook.
- Check that the initial
pingreceives HTTP 200. - Open an issue. Its delivery should return
{ "received": true, "created": true }. - Redeliver that event from GitHub's delivery history. Expect HTTP 200 with
created: falseand still one row ingithub_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
| Symptom | Check |
|---|---|
| Verification returns 400 | Match the secrets and JSON content type; preserve the raw body. Check the event type and payload schema too. |
| A valid event returns 403 | Match the numeric repository ID to APP_GITHUB_REPOSITORY_ID. |
| Delivery returns 401 before verification | Inspect app middleware or global session hooks that may require browser authentication. |
| Delivery returns 500 | Inspect 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 row | This workflow records only opened; define update behavior separately before handling other actions. |
Work with your agent
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.