# 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
- 1Push the repo to GitHub.
- 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.
- 3Set
GITHUB_WEBHOOK_SECRETas an env var. Use the same value when you create the webhook. - 4In your repo (or org) Settings → Webhooks, add
https://, set Content type to/github 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
| Concern | Free tier | Paid tier |
|---|---|---|
| Cold start on idle | Yes (scale-to-zero) | Stays warm |
| Cost | $0 | From low hourly rates |
| Good for | Low-traffic personal repos | CI 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 olderX-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).