--- title: "Receive a GitHub webhook" description: "Verify signed GitHub issue events, persist opened issues once, test duplicate deliveries, and recover failed deliveries." url: "https://www.beignetjs.com/guides/github-webhook" markdown_url: "https://www.beignetjs.com/markdown/guides/github-webhook.md" framework_version: "0.0.65" documentation_scope: "current-checkout" source_revision: "63ee4d92e831de857b32fdebb0d41a7da244f147" source_modified: true --- # 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](https://www.beignetjs.com/markdown/getting-started.md). 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](https://www.beignetjs.com/markdown/outbox.md) 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: ```diff --- a/lib/env.ts +++ b/lib/env.ts @@ -27,6 +27,8 @@ .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 ? BetterAuthSecret ``` 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](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries) 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; ``` ```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(), }); ``` ```diff --- a/infra/db/schema/index.ts +++ b/infra/db/schema/index.ts @@ -71,3 +71,5 @@ }, (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: ```diff --- a/infra/db/reset.ts +++ b/infra/db/reset.ts @@ -15,4 +15,5 @@ const tables = [ + "github_issues", "verification", "account", "session", ``` ```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, ): 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: ```diff --- a/ports/index.ts +++ b/ports/index.ts @@ -8,11 +8,13 @@ } 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]>; @@ -24,5 +26,6 @@ idempotency: IdempotencyPort; logger: LoggerPort; todos: TodoRepository; + githubIssues: GitHubIssueRepository; uow: UnitOfWorkPort; }; --- a/infra/db/repositories.ts +++ b/infra/db/repositories.ts @@ -1,5 +1,6 @@ import 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"; @@ -8,5 +9,6 @@ ): Omit { return { todos: createDrizzleTodoRepository(db), + githubIssues: createDrizzleGitHubIssueRepository(db), }; } --- a/infra/db/provider.ts +++ b/infra/db/provider.ts @@ -26,7 +26,7 @@ const repositories = createRepositories(dbPort.drizzle); const idempotency = createDrizzleSqliteIdempotencyPort(dbPort.drizzle); - const providedPorts: Pick = { + const providedPorts: Pick = { ...repositories, idempotency, uow: createDrizzleSqliteUnitOfWork({ --- a/infra/port-wiring.ts +++ b/infra/port-wiring.ts @@ -26,5 +26,5 @@ errorReporter: createNoopErrorReporter(), gate, }, - deferred: ["auth", "idempotency", "logger", "todos", "uow"], + deferred: ["auth", "idempotency", "logger", "todos", "uow", "githubIssues"], }); ``` ```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["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()({ 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](https://docs.github.com/en/webhooks/using-webhooks/creating-webhooks): 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](https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks). **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](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-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 ```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. ```