2. Your first webhook in 10 minutes
The fastest way to internalise the webhook model is to receive a real one. This section builds a 15-line Flask receiver, exposes it to the public internet via ngrok, registers it as a GitHub webhook on a repo you own, and waits for the first event. The whole loop takes about ten minutes and ends with a real GitHub payload arriving in your terminal. Production patterns (signature verification, persistence, idempotency) come in the next sections; this one is for proof-of-concept and intuition.
The minimal receiver
Save the following at the project root as minimal_receiver.py:
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/github")
def github_webhook():
event_type = request.headers.get("X-GitHub-Event", "unknown")
payload = request.get_json(silent=True) or {}
print(f"\nWebhook received: {event_type}")
print(f"Payload keys: {list(payload.keys())}\n")
return "", 200
if __name__ == "__main__":
app.run(port=5000)
That's it. Fifteen lines including the imports and the __main__ guard, eleven if you only count the substance. The receiver accepts GitHub POSTs, extracts the event type from headers, prints a summary, and returns 200 OK. Start it:
python minimal_receiver.py
Your Flask app is running locally on port 5000. GitHub can't reach it yet. That's where ngrok comes in.
Expose your server with ngrok
ngrok creates a secure tunnel from the internet to your laptop. Install it (ngrok.com/download), then run:
ngrok http 5000
ngrok displays a public URL like https://abc123.ngrok-free.app. Copy it. This URL routes to your local Flask server for the next few hours.
If you would rather not install ngrok, the alternatives all share the same shape (public URL points at localhost:5000): localtunnel via npx localtunnel --port 5000, or cloudflared via cloudflared tunnel --url http://localhost:5000. The chapter uses ngrok because its terminal output is the cleanest, but anything that gives you a public URL into your laptop works.
Configure GitHub webhook
Open any GitHub repository you own. Go to Settings โ Webhooks โ Add webhook. Configure:
- Payload URL: Your ngrok URL +
/webhooks/github
Example:https://abc123.ngrok-free.app/webhooks/github - Content type:
application/json - Secret: Leave blank for now (you'll add security later)
- Events: Select "Send me everything" or just "Issues"
Click Add webhook. GitHub immediately sends a ping event to verify the endpoint works.
See it work
Check your Flask terminal. You should see:
Webhook received: ping
Payload keys: ['zen', 'hook_id', 'hook', 'repository', 'sender']
127.0.0.1 - - [01/Feb/2026 14:23:45] "POST /webhooks/github HTTP/1.1" 200 -
The ping event confirms connectivity. Now trigger a real event. Open a new issue in your repository. Within seconds:
Webhook received: issues
Payload keys: ['action', 'issue', 'repository', 'sender']
127.0.0.1 - - [01/Feb/2026 14:24:12] "POST /webhooks/github HTTP/1.1" 200 -
GitHub just called your laptop. No polling, no delays. The event arrived the moment you clicked "Submit new issue."
What just happened
When you opened the issue, GitHub's servers detected the event, looked up the webhooks registered on that repository, and HTTP POSTed to your ngrok URL. ngrok tunnelled the request to localhost:5000, Flask routed it to your handler, you printed the summary, and returned 200 OK. GitHub logged the successful delivery. End-to-end round trip: under two seconds.
What's missing
This prototype proves webhooks work, but it's not production-ready:
- No signature verification (any HTTP client can POST fake events to your endpoint)
- No persistence (events disappear when Flask restarts)
- No duplicate handling (GitHub retries failed deliveries, causing duplicates)
- No background processing (slow work blocks the HTTP response)
The next sections fix all four problems. You'll add HMAC signature verification, store events in SQLite, design idempotent handlers, and separate fast acknowledgment from slow processing. But first, let's understand what GitHub actually sent you.