Oct 30, 20266 min read

How to Handle GitHub Webhooks in a TypeScript API

Verifying X-Hub-Signature-256 over the raw body, answering within GitHub's 10-second limit, deduplicating redeliveries by X-GitHub-Delivery and doing the work from a queue, with tested code that deploys to your own AWS or GCP account.

A GitHub webhook handler that works in testing tends to fail in two quiet ways in production. GitHub expects a 2xx response within 10 seconds, so a handler that calls the GitHub API, runs CI logic or starts an AI review agent inline starts timing out. And GitHub does not automatically redeliver failed deliveries, so every timeout is an event you lose unless someone notices and redelivers it by hand.

The fix is to split receiving from processing: verify the signature, put the event on a queue, answer, and let a worker with retries do the slow part. This guide implements that in TypeScript, with Postgres for deduplication and a signature check tested against GitHub's own test vector.

Quick start with Encore

Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions and deploys into your own AWS or GCP account. The queue, the database and the webhook secret are declared next to the handler, run locally with encore run, and deploy to your own AWS or GCP account.

Signature verification is a small function. GitHub signs the raw body with HMAC-SHA256 and sends sha256=<hex> in X-Hub-Signature-256:

// github/verify.ts import { createHmac, timingSafeEqual } from "node:crypto"; // GitHub sends X-Hub-Signature-256: sha256=<hex HMAC of the raw body>. export function verifySignature(secret: string, body: Buffer, header: string | undefined): boolean { if (!header?.startsWith("sha256=")) return false; const given = Buffer.from(header.slice("sha256=".length), "hex"); const expected = createHmac("sha256", secret).update(body).digest(); return given.length === expected.length && timingSafeEqual(given, expected); }

With the secret It's a Secret to Everybody and the body Hello, World!, it accepts GitHub's documented signature sha256=757107ea...8b043e17 and rejects it with any other secret, which is a quick way to check your own implementation.

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

The deliveries table is keyed by the delivery id:

-- github/migrations/1_create_deliveries.up.sql CREATE TABLE deliveries ( delivery_id TEXT PRIMARY KEY, -- X-GitHub-Delivery, kept on redelivery event TEXT NOT NULL, action TEXT, repo TEXT, received_at TIMESTAMPTZ NOT NULL DEFAULT now() );

The webhook is a raw endpoint, so the body reaches the HMAC exactly as GitHub sent it. It publishes to a Pub/Sub topic and answers 202, and the subscriber does the work:

// github/webhook.ts import { api } from "encore.dev/api"; import { secret } from "encore.dev/config"; import { Topic, Subscription } from "encore.dev/pubsub"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import log from "encore.dev/log"; import { verifySignature } from "./verify"; const db = new SQLDatabase("github", { migrations: "./migrations" }); // The secret you entered when creating the webhook in GitHub. const webhookSecret = secret("GitHubWebhookSecret"); export interface GitHubDelivery { deliveryId: string; event: string; // X-GitHub-Event, e.g. "pull_request" action: string | null; repo: string | null; } export const deliveries = new Topic<GitHubDelivery>("github-deliveries", { deliveryGuarantee: "at-least-once", }); // A raw endpoint, because the HMAC is computed over the exact request bytes. export const githubWebhook = api.raw( { expose: true, method: "POST", path: "/webhooks/github" }, async (req, resp) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks); const signature = req.headers["x-hub-signature-256"] as string | undefined; if (!verifySignature(webhookSecret(), body, signature)) { resp.writeHead(401).end(); return; } const payload = JSON.parse(body.toString("utf8")); await deliveries.publish({ deliveryId: req.headers["x-github-delivery"] as string, event: req.headers["x-github-event"] as string, action: payload.action ?? null, repo: payload.repository?.full_name ?? null, }); // GitHub expects a 2xx within 10 seconds, so acknowledge before doing the work. resp.writeHead(202).end(); }, ); const _ = new Subscription(deliveries, "handle-delivery", { handler: async (d) => { // Redeliveries keep the same X-GitHub-Delivery id, so record each id once. // The insert commits only after the work succeeds, so a failed attempt is retried. await using tx = await db.begin(); const row = await tx.queryRow` INSERT INTO deliveries (delivery_id, event, action, repo) VALUES (${d.deliveryId}, ${d.event}, ${d.action}, ${d.repo}) ON CONFLICT (delivery_id) DO NOTHING RETURNING delivery_id`; if (!row) return; if (d.event === "pull_request" && d.action === "opened") { log.info("pull request opened", { repo: d.repo }); // Post a comment, start a review agent, update a dashboard... } await tx.commit(); }, retryPolicy: { maxRetries: 5, minBackoff: "5s" }, });

The endpoint does no slow work, so it stays far inside the 10-second limit. When the subscriber fails, for example on a GitHub API rate limit, it is retried with backoff and then dead-lettered instead of becoming a failed delivery in GitHub. Both kinds of duplicates, a manual redelivery from GitHub and an at-least-once redelivery from the queue, hit the ON CONFLICT on delivery_id and are skipped.

To test locally, set the secret, start the app and send a signed request:

~/bot
$ encore secret set --type local GitHubWebhookSecret$ encore run $ BODY='{"action":"opened","repository":{"full_name":"acme/api"}}'$ SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')$ curl -X POST localhost:4000/webhooks/github \ -H "X-Hub-Signature-256: sha256=$SIG" \ -H "X-GitHub-Event: pull_request" \ -H "X-GitHub-Delivery: test-1" \ -d "$BODY"

Set SECRET to the value you stored. For real events from a repository, GitHub's testing guide uses smee.io to forward deliveries to localhost:4000/webhooks/github.

On deploy, Encore provisions the topic as SNS and SQS on AWS or Pub/Sub on GCP, the database as RDS or Cloud SQL, and the secret in the cloud's secret manager, in your own account. Set the production secret with encore secret set --type prod GitHubWebhookSecret and use the same value when you create the webhook in the repository or GitHub App settings.

Without Encore: Express or Fastify

The verification function works anywhere. In Express, mount the route with express.raw({ type: "application/json" }) before any global express.json(), so req.body is a Buffer. In Fastify, add a content type parser that keeps the body as a Buffer for that route. Octokit's @octokit/webhooks also verifies signatures and gives you typed payloads per event.

The queue is the part that takes setup: SQS with a dead-letter queue and a worker process that polls it, or a job library on Redis, plus the IAM permissions, the deployment for the worker and the secret in Secrets Manager.

Good to know

  • Check X-GitHub-Event and action before processing, and subscribe the webhook only to the events you handle.
  • Payloads are capped at 25 MB, and GitHub does not send deliveries that exceed it.
  • JSON.parse the body only after verification, so unauthenticated requests never reach your parsing code.
  • If your firewall restricts inbound traffic, GitHub publishes its webhook source IP ranges in the hooks key of the meta API.

How to choose

Use Encore when you want the webhook, queue, deduplication table and secret in one TypeScript codebase that runs locally and deploys to your own AWS or GCP account. Use Express or Fastify with @octokit/webhooks when the webhook lives in an existing server and you already run a queue and workers you can reuse.

Frequently asked questions

How do I verify a GitHub webhook signature in TypeScript?

Compute an HMAC-SHA256 of the raw request body with your webhook secret, prefix the hex digest with sha256=, and compare it to the X-Hub-Signature-256 header with crypto.timingSafeEqual. Use the raw bytes, not a parsed and re-serialized JSON object, or the signatures will not match.

Does GitHub retry failed webhook deliveries?

No. GitHub does not automatically redeliver failed deliveries. You can redeliver them by hand from the webhook settings or through the REST API, and a redelivery keeps the same X-GitHub-Delivery header, which makes that header the right key for deduplication.

How long does GitHub wait for a webhook response?

GitHub expects a 2xx response within 10 seconds, otherwise it marks the delivery as failed. Verify the signature, queue the event and respond, then do slow work such as API calls or running an agent from the queue.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders