How to Receive Webhooks from GitHub
Self-host a GitHub webhook receiver with verified HMAC signatures in 10 minutes. Every command tested against the real Docker image.
By the end of this guide you will have a self-hosted endpoint that receives GitHub webhooks, verifies their HMAC signatures, and routes the payloads wherever you need them: a log, RabbitMQ, S3, a database. Every command below was run and verified against the real Core Webhook Module Docker image before publishing; you can copy-paste them in order.
How GitHub webhooks work
When an event happens in a repository (a push, a pull request, a release), GitHub sends an HTTP POST to your payload URL with these headers:
X-GitHub-Event: the event name (push,pull_request,ping, ...)X-GitHub-Delivery: a unique GUID for the deliveryX-Hub-Signature-256:sha256=followed by the HMAC-SHA256 hex digest of the raw request body, computed with your webhook secretUser-Agent: always prefixedGitHub-Hookshot/
Three delivery facts worth knowing before you build:
- GitHub does not automatically retry failed deliveries. You can redeliver manually from the webhook's "Recent Deliveries" tab for up to 3 days. Your receiver being up matters.
- Payloads are capped at 25 MB; larger events are simply not delivered.
- When you create a webhook, GitHub immediately sends a
pingevent so you can confirm the wiring.
The signature is the security boundary: anyone on the internet can POST to your URL, so every request must be verified against the shared secret using a constant-time comparison. Core Webhook Module does this for you.
Prerequisites
- Docker installed (any machine, including your laptop for the test run)
- A public HTTPS URL for production use. Two ways to get one:
- HTTPS for webhooks with nginx and Let's Encrypt if you have a public server
- Receiving webhooks through Cloudflare Tunnel if you do not want to open any inbound ports
Step 1: Configure the receiver
Create a working directory with two files.
webhooks.json:
{
"github_events": {
"data_type": "json",
"module": "log",
"hmac": {
"secret": "{$GITHUB_WEBHOOK_SECRET}",
"header": "X-Hub-Signature-256",
"algorithm": "sha256"
}
}
}
connections.json:
{}
What each parameter does:
github_eventsis the webhook ID; it becomes the URL path (/webhook/github_events).module: logprints each verified payload to the container log. Zero dependencies, perfect for the first run; we swap in a real destination at the end.hmac.headerandhmac.algorithmmatch what GitHub sends. Thesha256=prefix in the header value is handled automatically.{$GITHUB_WEBHOOK_SECRET}pulls the secret from an environment variable, so it never lives in a config file.
Generate a strong secret (or use our webhook secret generator):
openssl rand -hex 32
Step 2: Run it
docker run -d --name webhook-gateway -p 8000:8000 \
-v "$PWD/webhooks.json:/app/webhooks.json:ro" \
-v "$PWD/connections.json:/app/connections.json:ro" \
-e GITHUB_WEBHOOK_SECRET="your-generated-secret" \
spiderhash/webhook:latest
Confirm it is up:
docker logs webhook-gateway
# ... INFO: Application startup complete.
The receiver now answers on http://localhost:8000/webhook/github_events, and its auto-generated API docs are at http://localhost:8000/docs.
Step 3: Test the signature verification locally
You do not need GitHub to test; you need a correctly signed request. Save a realistic ping payload:
cat > ping.json <<'EOF'
{"zen":"Design for failure.","hook_id":123456,"hook":{"type":"Repository","id":123456,"events":["push"]},"repository":{"full_name":"acme/api"},"sender":{"login":"octocat"}}
EOF
Sign it exactly the way GitHub does (HMAC-SHA256 over the raw body) and send it:
SIG=$(openssl dgst -sha256 -hmac "your-generated-secret" < ping.json | awk '{print $2}')
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:8000/webhook/github_events \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: ping" \
-H "X-Hub-Signature-256: sha256=$SIG" \
--data-binary @ping.json
Expected result:
{"message":"200 OK"}
HTTP 200
Now prove the security works. A tampered signature:
{"detail":"Invalid HMAC signature"}
HTTP 401
And a request with no signature header at all:
{"detail":"Missing X-Hub-Signature-256 header"}
HTTP 401
Those are the exact responses the gateway returns. If you want to sanity-check your HMAC tooling itself, GitHub documents an official test vector: secret It's a Secret to Everybody and body Hello, World! must produce sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. The openssl command above reproduces it exactly.
Step 4: Create the webhook in GitHub
In your repository: Settings > Webhooks > Add webhook, then:
- Payload URL:
https://your-domain/webhook/github_events - Content type:
application/json. (Thex-www-form-urlencodedoption wraps the JSON in a form field calledpayload; pick JSON so the body arrives as-is.) - Secret: the same value you passed as
GITHUB_WEBHOOK_SECRET - SSL verification: leave enabled
- Events: "Just the push event" is fine to start; you can select individual events later
The same form exists at the organization level under Organization Settings > Webhooks if you want one endpoint for all repositories.
The moment you save, GitHub sends a ping. Check both sides: the delivery should show a green check under Recent Deliveries, and docker logs webhook-gateway should show the ping payload. Failed? Open the delivery in GitHub to see your endpoint's exact response, fix, and press Redeliver.
Troubleshooting
401 {"detail":"Invalid HMAC signature"}on real GitHub deliveries: the secrets differ. The secret in GitHub's webhook form andGITHUB_WEBHOOK_SECRETmust be byte-identical (watch for trailing whitespace or shell quoting).401 {"detail":"Missing X-Hub-Signature-256 header"}: you left GitHub's Secret field empty. GitHub only signs deliveries when a secret is set.- Ping arrives but pushes do not: check which events the webhook subscribes to in its settings.
- Nothing arrives at all: your URL is not reachable over HTTPS from the internet. Test with
curlfrom another network, and see the prerequisites section for the nginx and Cloudflare guides.
Going to production
Printing to logs is not a pipeline. Swap the module for a real destination; here is the RabbitMQ variant, verified end to end (the signed test above landed as a message in the github_events queue):
webhooks.json:
{
"github_events": {
"data_type": "json",
"module": "rabbitmq",
"connection": "rabbitmq_local",
"module-config": {
"queue_name": "github_events"
},
"hmac": {
"secret": "{$GITHUB_WEBHOOK_SECRET}",
"header": "X-Hub-Signature-256",
"algorithm": "sha256"
}
}
}
connections.json:
{
"rabbitmq_local": {
"type": "rabbitmq",
"host": "{$RABBITMQ_HOST:localhost}",
"port": "{$RABBITMQ_PORT:5672}",
"user": "{$RABBITMQ_USER:guest}",
"pass": "{$RABBITMQ_PASS:guest}"
}
}
The same pattern works for 17 other destinations: archive every delivery to S3, insert into PostgreSQL, publish to Kafka, or fan out to several at once with webhook chaining. Built-in rate limiting and retry handling cover the operational edges, and since GitHub itself never retries automatically, having the gateway persist deliveries to a durable queue is exactly the safety net you want.