Back to Blog
Tutorial10 min read2026-06-30

How to Deploy a GitHub Webhook Receiver

Build and deploy a GitHub webhook receiver that verifies HMAC signatures, routes by event type, and responds fast enough to avoid GitHub's delivery timeouts.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

# How to Deploy a GitHub Webhook Receiver

GitHub webhooks let you react to pushes, pull requests, issues, releases — anything happening in a repo or org. The receiver is a public HTTP endpoint, and like all webhook endpoints it has to verify the sender, respond quickly, and tolerate redelivery. This guide builds one and deploys it.

What GitHub sends

For every subscribed event, GitHub POSTs JSON with a few important headers:

  • X-GitHub-Event — the event type (push, pull_request, ping, etc.).
  • X-GitHub-Delivery — a unique GUID per delivery, perfect for idempotency.
  • X-Hub-Signature-256 — an HMAC-SHA256 of the body, keyed with your webhook secret.

GitHub expects a response within its timeout window; if you're slow or return an error, the delivery is marked failed (you can redeliver from the UI).

Verify the signature with the raw body

As with Stripe, you need the raw bytes to compute the HMAC, and you must use a constant-time comparison to avoid timing attacks:

const express = require('express');
const crypto = require('crypto');

const app = express();
const secret = process.env.GITHUB_WEBHOOK_SECRET;

app.post('/github', express.raw({ type: '*/*' }), (req, res) => {
  const sig = req.headers['x-hub-signature-256'] || '';
  const expected = 'sha256=' +
    crypto.createHmac('sha256', secret).update(req.body).digest('hex');

  const a = Buffer.from(sig);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('Bad signature');
  }

  const event = req.headers['x-github-event'];
  const delivery = req.headers['x-github-delivery'];
  const payload = JSON.parse(req.body.toString('utf8'));

  res.status(202).send('accepted'); // ack fast
  route(event, delivery, payload).catch(console.error); // work after
});

Note the order: respond 202 first, then process. GitHub doesn't need your work to finish, just your acknowledgment.

Route by event type

async function route(event, delivery, payload) {
  if (await alreadyHandled(delivery)) return; // idempotency on X-GitHub-Delivery
  switch (event) {
    case 'ping':
      return; // GitHub sends this on webhook creation
    case 'push':
      return onPush(payload);
    case 'pull_request':
      return onPullRequest(payload); // payload.action: opened/closed/synchronize
    case 'release':
      return onRelease(payload);
    default:
      console.log('Unhandled event:', event);
  }
}

Always handle ping — GitHub fires it when you first create the webhook and uses the response to confirm reachability.

Idempotency

GitHub may redeliver. Store X-GitHub-Delivery GUIDs (a Redis set with TTL, or a Postgres table with a unique constraint) and skip duplicates. This matters most when your handler triggers deployments, posts comments, or mutates state.

Containerize

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "index.js"]

Deploy on PandaStack

  1. 1Push the repo to GitHub.
  2. 2Create a container app in the [dashboard](https://dashboard.pandastack.io) connected to the repo — it builds via rootless BuildKit and deploys with an HTTPS URL and automatic SSL.
  3. 3Set GITHUB_WEBHOOK_SECRET as an env var. Use the same value when you create the webhook.
  4. 4In your repo (or org) Settings → Webhooks, add https:///github, set Content type to application/json, paste the secret, and choose the events you care about.

If you need durable idempotency or to queue downstream work, add a managed Redis or PostgreSQL — DATABASE_URL is auto-wired into the app environment.

Free tier vs. paid for this workload

ConcernFree tierPaid tier
Cold start on idleYes (scale-to-zero)Stays warm
Cost$0From low hourly rates
Good forLow-traffic personal reposCI triggers / org-wide hooks

For a personal repo that pushes a few times a day, the free tier is fine — GitHub will simply redeliver if the first request cold-starts too slowly. For org-wide hooks that fan out to many events, keep an instance warm.

Verify the delivery

GitHub's webhook UI has a Recent Deliveries tab showing the exact request, your response code, and a one-click Redeliver button. Pair that with PandaStack's live logs to debug:

  • 401 → secret mismatch or you parsed the body before hashing it.
  • Timeout → you did work before responding; move it after the res.send.
  • No deliveries at all → wrong URL or the event isn't subscribed.

Security notes

  • Always verify X-Hub-Signature-256; the older X-Hub-Signature (SHA1) is deprecated.
  • Treat the payload as untrusted input even after signature verification.
  • Scope your webhook to only the events you handle to reduce noise and attack surface.

References

  • [GitHub: Webhook events and payloads](https://docs.github.com/en/webhooks/webhook-events-and-payloads)
  • [GitHub: Validating webhook deliveries](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)
  • [GitHub: Best practices for webhooks](https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks)
  • [Node crypto.timingSafeEqual](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b)

A webhook receiver is the backbone of any GitHub automation. PandaStack gives you HTTPS, env-var secrets, and auto-wired datastores for idempotency on the free tier — build one at [dashboard.pandastack.io](https://dashboard.pandastack.io).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also