How to Handle GitHub Webhooks on Serverless

Verify x-hub-signature-256 with a timing-safe HMAC (or the turnkey webhookMode: 'github' route), route by x-github-event, dedupe by x-github-delivery, ack fast, then run CI/CD, bots, and notifications in async background jobs.

How to Handle GitHub Webhooks on Serverless

GitHub webhooks are the backbone of most automation you will ever build: run CI on a push, review a pull request, cut a release, label an issue, sync a mirror, re-index a repo. They are also deceptively easy to get wrong. The payloads are large, the event types are many, the same delivery can arrive twice, and GitHub keeps retrying a failed endpoint for up to three days. Get the security or the idempotency wrong and you either leak a forged-payload attack surface or trigger the same deploy three times.

This is a practical guide to serverless GitHub webhooks: how to verify the signature, route by event type, dedupe redeliveries, acknowledge fast, and push the slow work into a background job. Every code sample runs as an ordinary handler function behind a gateway route, and every claim maps to something the platform actually does.

Why serverless GitHub webhooks are harder than they look

A webhook is just an HTTP POST, so the naive version is a single handler that parses the body and does the work inline. That version breaks in production for four predictable reasons.

First, anyone can POST to a public URL. Without signature verification, a forged pull_request or push payload can drive your automation. The fix is an HMAC check on the raw body — the part most people either skip “temporarily” or implement with a non-constant-time string compare.

Second, GitHub retries. If your endpoint is slow or returns a non-2xx, GitHub redelivers, for up to three days. If your handler ran a deploy before returning, a retry runs it again. There is no exactly-once delivery on the internet, so duplicate handling is not optional.

Third, one endpoint receives dozens of event types. push, pull_request, release, issues, issue_comment, workflow_run, and each carries sub-actions. Cramming all of that into one synchronous function turns into a monolith that is hard to test and easy to break.

Fourth, the real work is slow. Triggering CI, indexing a repo, calling an LLM to review a diff — none of that fits inside the webhook request. You must acknowledge quickly and continue asynchronously.

The pattern that survives all four: verify, dedupe, route, ack fast, then hand off to a background job.

Verify GitHub webhook signature with x-hub-signature-256 and HMAC

To verify a GitHub webhook signature you compute an HMAC-SHA256 over the exact raw request body using your webhook secret, and compare it to the value GitHub sends in the x-hub-signature-256 header. The header looks like sha256=<hex>.

Two rules make or break this webhook HMAC check.

Verify the raw bytes, before you parse. GitHub signs the exact payload it sent. If you JSON.parse and re-serialize, key order and whitespace shift, the bytes change, and a valid event fails verification. Always read event.body as a string and feed that string to the HMAC — never a re-stringified object.

Compare in constant time. A plain === on the signature leaks timing information an attacker can use to forge a signature byte by byte. Use crypto.timingSafeEqual, which requires equal-length buffers, so check the length first and short-circuit.

Here is the verification, isolated so it stays small and reviewable:

import { createHmac, timingSafeEqual } from 'node:crypto';

// Verify the GitHub webhook signature over the RAW request body.
// GitHub sends: x-hub-signature-256: sha256=<hex hmac>
export function verifyGithubSignature(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = signatureHeader ?? '';
  // Buffers must be equal length before timingSafeEqual, and we compare in constant time.
  if (received.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

The webhook secret lives in the function’s environment configuration, injected at invoke time — not in the code, not in the payload. Store it once and reference it as process.env.GITHUB_WEBHOOK_SECRET.

Turnkey verification with webhookMode: ‘github’

Writing the HMAC check yourself is fine, but it is boilerplate you now own on every webhook route. The gateway can do it for you. Set webhookMode: 'github' on the route and the gateway verifies x-hub-signature-256 on the raw body before your function is ever invoked. On a mismatch it returns 403 { error: { code: 'BAD_SIGNATURE' } } and your handler never sees the forged request.

// Gateway route settings for POST /webhooks/github (configured in the console/API).
// webhookMode: 'github' makes the gateway verify x-hub-signature-256 on the raw body
// BEFORE your function runs. On mismatch it returns 403 { error: { code: 'BAD_SIGNATURE' } }.
{
  method: 'POST',
  path: '/webhooks/github',
  target: 'github-webhook',                 // the function to invoke
  webhookMode: 'github',                    // turnkey HMAC-SHA256 verification
  webhookSecret: '{{ GITHUB_WEBHOOK_SECRET }}',
  // signatureHeader defaults to x-hub-signature-256 in github mode
}

This is opt-in and per route — verification is off until you turn it on for that specific endpoint. GitHub and Stripe are the two built-in modes (Stripe additionally checks its timestamp for replay tolerance). Everything else, including Slack, is not a turnkey mode — you verify those in your handler with the helper above, because their signing schemes differ.

The practical payoff: with gateway verification on, the handler drops the whole HMAC block. It assumes an already-authenticated caller and focuses on what it is actually for — dedupe and routing.

The one behavioral difference worth knowing: the gateway returns 403 BAD_SIGNATURE, while a hand-rolled check can return whatever you like (401 or 403). GitHub does not care which non-2xx it gets; it only cares that verification failed. Pick one and be consistent.

Route by x-github-event, dedupe by x-github-delivery

Once the payload is trusted, two headers do the routing and the deduplication.

x-github-event tells you the event type: push, pull_request, release, issues, and so on. Switch on it. Many events also carry a payload.action — a pull_request can be opened, synchronize, reopened, closed; a release can be published, edited, deleted. Route on both so you only act on what you mean to.

x-github-delivery is a unique UUID per delivery. It is your idempotency key. Record it before doing work; if you have seen it, return 200 and stop. This is how you survive GitHub’s redelivery-for-three-days behavior without double-triggering.

The github webhook handler ties it together — verify (or skip if the gateway already did), answer the ping GitHub sends when you first register the hook, dedupe, route, and enqueue:

import { verifyGithubSignature } from './verify.mjs';

// github webhook handler: verify, dedupe by delivery id, route by event type, ack fast.
export async function handler(event) {
  const rawBody = event.body ?? '';         // string as delivered — never JSON.parse before verifying
  const signature = event.headers['x-hub-signature-256'];

  // Skip this block if the gateway already verified via webhookMode: 'github'.
  if (!verifyGithubSignature(rawBody, signature, process.env.GITHUB_WEBHOOK_SECRET)) {
    return { statusCode: 401, body: 'invalid signature' };
  }

  const deliveryId = event.headers['x-github-delivery'];  // unique UUID per delivery
  const eventType = event.headers['x-github-event'];      // push, pull_request, release, ping, ...

  if (eventType === 'ping') return { statusCode: 200, body: 'pong' };

  // Idempotency: record the delivery id first; skip work if we have seen it.
  if (!(await db.deliveries.markSeen(deliveryId))) {
    return { statusCode: 200, body: 'duplicate' };
  }

  const payload = JSON.parse(rawBody);      // safe to parse after verification
  switch (eventType) {
    case 'push':
      await global.durable.startNew('run-ci', undefined, {
        deliveryId, repo: payload.repository.full_name, sha: payload.after, ref: payload.ref,
      });
      break;
    case 'pull_request':
      if (['opened', 'synchronize', 'reopened'].includes(payload.action)) {
        await global.durable.startNew('review-pr', undefined, {
          deliveryId, repo: payload.repository.full_name, pr: payload.number,
        });
      }
      break;
    case 'release':
      if (payload.action === 'published') {
        await global.durable.startNew('deploy-release', undefined, {
          deliveryId, repo: payload.repository.full_name, tag: payload.release.tag_name,
        });
      }
      break;
    // Any other event still gets a fast 200 so GitHub stops retrying.
  }
  return { statusCode: 200, body: 'accepted' };
}

Note that unhandled event types still fall through to a fast 200. Silence is the goal — you do not want GitHub retrying an event you deliberately ignore.

Fast ack, then async work: CI/CD, bots, and notifications

The handler above does not run CI, review a PR, or deploy. It calls global.durable.startNew(name, undefined, payload) and returns. That call enqueues a durable, Postgres-backed background job that survives restarts, then answers GitHub in milliseconds — well inside the delivery window.

Why the split? Because functions have a timeout: 5 seconds by default, 15 minutes maximum. A synchronous handler that waits on a CI system or an LLM will blow that budget and, worse, will look like a failure to GitHub, which then retries. Acknowledge first; do the slow work outside the request.

The job is an ordinary handler. It reads event.payload and runs to completion in its own isolated container with its own timeout budget:

// jobs/run-ci.mjs — a plain function enqueued by global.durable.startNew.
// Runs outside the webhook request in its own container (timeout up to 15 min).
export async function handler(event) {
  const { repo, sha, ref } = event.payload ?? {};

  // The real guarantee is idempotency: keyed on repo+sha, a re-run is a safe no-op.
  const run = await db.ciRuns.find(repo, sha);
  if (run?.status === 'done') return { repo, sha, skipped: true };

  await db.ciRuns.upsert({ repo, sha, ref, status: 'running' });
  const result = await triggerBuild({ repo, sha, ref });   // call your CI/CD system
  await db.ciRuns.upsert({ repo, sha, status: 'done', result });
  await postCommitStatus(repo, sha, 'success');            // bot action back to GitHub
  return { repo, sha, status: 'done' };
}

This is the shape for every serverless GitHub webhook side effect. A push fans out to indexing and CI. A pull_request fans out to a bot that runs a linter and posts a review comment. A release fans out to a deploy plus a Slack notification. Each is its own job with its own logs, so an on-call engineer can see exactly which step failed on which delivery. Every run lands in execution history, retained for 30 days.

Notice the idempotency guard inside the job, keyed on repo + sha. That is deliberate. The handler-level x-github-delivery dedupe is a fast filter, but it is not a guarantee — under at-least-once delivery a job can still be enqueued more than once. The job staying idempotent is the real safety net. Design side effects so a replay is a no-op: check-then-write, upsert on a stable key, never blind-insert.

If a job step can fail transiently, you can opt into retries with exponential backoff, and a job that exhausts its attempts dead-letters with its last error instead of vanishing. Retries are opt-in per job, not automatic for every enqueue, so decide per job whether a retry is safe — which comes back to keeping the handler idempotent.

Testing: redeliver from the GitHub UI

The best part of GitHub webhooks is that testing does not require guesswork. GitHub keeps a log of every delivery with the exact payload and headers it sent.

Open your repository (or organization) Settings → Webhooks → your hook → Recent Deliveries. Each entry shows the request payload, the response your endpoint returned, and a Redeliver button. Redeliver sends the identical payload again — same x-github-delivery, same signature. This is the fastest way to exercise your handler against a real event, and it is exactly how you confirm your idempotency works: redeliver a push and verify the second attempt returns duplicate and does not re-run CI.

For local development, forward real events to a function on your machine with the GitHub CLI:

gh webhook forward --repo owner/repo --events push --url http://localhost:PORT

Keep the same event.body string contract locally that the gateway delivers in production, so verification behaves identically in both places. And when you first register the hook, expect a ping event — the handler answers it with pong and moves on.

Honest limits and trade-offs

The pattern is solid, but a pragmatic build accounts for what the platform does not promise.

Cold starts are reduced, not eliminated. Hot container pools (min 1, up to 8 warm per function) keep a warm instance ready for steady traffic, but a first invoke or a wake-from-idle is still a cold path. GitHub’s delivery window is generous, so this rarely bites a webhook, but do not design as if latency is always warm.

There is no exactly-once and no guaranteed ordering. Delivery is at-least-once. Two push events can be processed out of order; a delivery can arrive twice. Your only durable defense is idempotent handlers keyed on stable identifiers — x-github-delivery, or repo + sha. Do not architect anything that assumes order or single delivery.

A function timeout is real: 5s default, 15 minutes max. For work longer than a single step, chain steps in a pipeline rather than reaching for one unbounded function. The webhook handler should never be the thing doing the long work.

Only GitHub and Stripe are turnkey at the gateway. Slack, Shopify, and anything custom verify in the handler. Slack in particular signs v0:timestamp:body, not GitHub’s scheme, so its verification is your code, not a route setting.

global.durable.startNew is the Node.js helper. These examples are Node.js 22. From a Python or Go function you enqueue by POSTing to the pipeline trigger URL with the same payload shape — the trigger is HTTP-accessible from any runtime.

The secret store is env config, not a full secrets manager. It injects your webhook secret at invoke and redacts it from logs and traces; it is encrypted at rest when the platform’s encryption key is configured. There is no built-in rotation or versioning, so rotate the GitHub secret and the stored value together.

Async throughput is bounded. Async invokes are rate-limited to 120 per minute per tenant. A busy monorepo with fan-out on every push can approach that — batch or coalesce work per delivery if you expect bursts.

Takeaway

Serverless GitHub webhooks come down to five moves, in order: verify the signature on the raw body, dedupe on x-github-delivery, route on x-github-event, acknowledge fast, and run the real work in an idempotent background job.

The signature check is the one you cannot skip — an HMAC-SHA256 over the raw body, compared in constant time against x-hub-signature-256. Let the gateway do it with webhookMode: 'github' so a forged payload gets a 403 before your code runs, and your handler shrinks to routing and dedup. Then let a durable, Postgres-backed job carry the CI trigger, the bot action, or the notification, so a redelivery three days later is a safe no-op instead of a second deploy.

Wire one event first — the push from your most active repo — confirm a redelivery returns duplicate, and grow the routing from there.