backend
intermediate
Webhook Handling: Stripe and GitHub
min read
Frederick Tubiermont
Webhook Handling: Stripe and GitHub
Webhooks are HTTP POST requests that third-party services send to your app when events happen: a payment succeeds, a pull request is opened, a subscription is cancelled. Here's how to handle them correctly.
The Golden Rules of Webhook Handling
- Respond with 200 immediately — the sender will retry if you take too long
- Verify the signature — confirm the request is genuinely from the sender
- Be idempotent — the same event may be delivered more than once; handle it gracefully
- Do real work in the background — queue heavy processing via Celery
Database Table for Idempotency
CREATE TABLE webhook_events (
id SERIAL PRIMARY KEY,
source VARCHAR(50) NOT NULL, -- 'stripe', 'github'
event_id VARCHAR(255) NOT NULL, -- External event ID
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
processed BOOLEAN DEFAULT FALSE,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(source, event_id) -- Prevents duplicate processing
);
Stripe Webhooks
Setup
In the Stripe Dashboard: Developers → Webhooks → Add endpoint → your URL.
STRIPE_WEBHOOK_SECRET=whsec_your_secret_here
The Endpoint
import stripe
import os
from flask import request, jsonify
from utils.db import get_db
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY")
@app.route("/webhooks/stripe", methods=["POST"])
def stripe_webhook():
payload = request.get_data(as_text=True)
sig_header = request.headers.get("Stripe-Signature")
webhook_secret = os.environ.get("STRIPE_WEBHOOK_SECRET")
# Step 1: Verify the signature
try:
event = stripe.Webhook.construct_event(
payload, sig_header, webhook_secret
)
except stripe.SignatureVerificationError:
return jsonify({"error": "Invalid signature"}), 400
# Step 2: Store for idempotency, skip if already processed
with get_db() as conn:
with conn.cursor() as cur:
try:
cur.execute("""
INSERT INTO webhook_events (source, event_id, event_type, payload)
VALUES ('stripe', %s, %s, %s)
""", (event["id"], event["type"], payload))
conn.commit()
except Exception:
# UNIQUE constraint violated — already received this event
conn.rollback()
return jsonify({"status": "already processed"}), 200
# Step 3: Respond immediately, process asynchronously
handle_stripe_event.delay(event["id"], event["type"], event["data"])
return jsonify({"status": "received"}), 200
Handling Stripe Event Types
# tasks/webhook_tasks.py
from app import celery
from utils.db import get_db
@celery.task
def handle_stripe_event(event_id: str, event_type: str, data: dict):
"""Process a Stripe event in the background."""
if event_type == "checkout.session.completed":
session = data["object"]
customer_email = session.get("customer_email")
amount = session.get("amount_total")
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("""
UPDATE orders
SET status = 'paid', paid_at = NOW()
WHERE stripe_session_id = %s
""", (session["id"],))
conn.commit()
elif event_type == "customer.subscription.deleted":
subscription = data["object"]
# Revoke access, send cancellation email, etc.
elif event_type == "invoice.payment_failed":
invoice = data["object"]
# Notify user, update subscription status
# Mark as processed
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE webhook_events SET processed = TRUE WHERE source = 'stripe' AND event_id = %s",
(event_id,)
)
conn.commit()
GitHub Webhooks
Setup
In your GitHub repo: Settings → Webhooks → Add webhook.
GITHUB_WEBHOOK_SECRET=your-secret-here
The Endpoint
import hmac
@app.route("/webhooks/github", methods=["POST"])
def github_webhook():
# Step 1: Verify signature (HMAC-SHA256)
secret = os.environ.get("GITHUB_WEBHOOK_SECRET", "").encode()
signature = request.headers.get("X-Hub-Signature-256", "")
payload = request.get_data()
expected = "sha256=" + hmac.digest(secret, payload, "sha256").hex()
if not hmac.compare_digest(expected, signature):
return jsonify({"error": "Invalid signature"}), 400
# Step 2: Parse the event
event_type = request.headers.get("X-GitHub-Event")
delivery_id = request.headers.get("X-GitHub-Delivery")
data = request.get_json()
# Step 3: Store for idempotency
with get_db() as conn:
with conn.cursor() as cur:
try:
cur.execute("""
INSERT INTO webhook_events (source, event_id, event_type, payload)
VALUES ('github', %s, %s, %s)
""", (delivery_id, event_type, request.get_data(as_text=True)))
conn.commit()
except Exception:
conn.rollback()
return jsonify({"status": "already processed"}), 200
# Step 4: Handle the event
if event_type == "push":
branch = data["ref"].replace("refs/heads/", "")
commit_message = data["head_commit"]["message"]
# Trigger a deployment, notify Slack, update a dashboard, etc.
elif event_type == "pull_request":
action = data["action"] # opened, closed, merged
pr_title = data["pull_request"]["title"]
return jsonify({"status": "received"}), 200
Testing Webhooks Locally
Use the Stripe CLI to forward events to localhost:
stripe listen --forward-to localhost:5000/webhooks/stripe
For GitHub, use ngrok to expose your local server:
ngrok http 5000
# Use the ngrok URL as your webhook endpoint in GitHub settings
Viewing Webhook History
@app.route("/admin/webhooks")
@admin_required
def webhook_log():
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT source, event_type, processed, received_at
FROM webhook_events
ORDER BY received_at DESC
LIMIT 100
""")
events = cur.fetchall()
return render_template("admin/webhooks.html", events=events)
Was this helpful?
Get More Flask Vibe Tutorials
Join 1,000+ developers getting weekly Flask tips and AI-friendly code patterns.
No spam. Unsubscribe anytime.