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 delivery
  • X-Hub-Signature-256: sha256= followed by the HMAC-SHA256 hex digest of the raw request body, computed with your webhook secret
  • User-Agent: always prefixed GitHub-Hookshot/

Three delivery facts worth knowing before you build:

  1. 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.
  2. Payloads are capped at 25 MB; larger events are simply not delivered.
  3. When you create a webhook, GitHub immediately sends a ping event 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

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_events is the webhook ID; it becomes the URL path (/webhook/github_events).
  • module: log prints each verified payload to the container log. Zero dependencies, perfect for the first run; we swap in a real destination at the end.
  • hmac.header and hmac.algorithm match what GitHub sends. The sha256= 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. (The x-www-form-urlencoded option wraps the JSON in a form field called payload; 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 and GITHUB_WEBHOOK_SECRET must 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 curl from 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.

Subscribe to Free Webhook Tool

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe