Webhook Testing
Third-party services (Stripe, GitHub, Shopify, Twilio) need a public
URL to deliver webhooks. This guide shows how to receive webhooks in
your local kindling environment using kindling expose.
The problem
Your app runs on *.localhost. Stripe can't reach that. You need a
public HTTPS URL that forwards to your local cluster.
┌──────────────┐ ┌──────────────────┐ ┌───────────────┐
│ Stripe / │────▶│ Tunnel │────▶│ Your app │
│ GitHub / │ │ (kindling │ │ on kindling │
│ Shopify │ │ expose) │ │ :8000 │
└──────────────┘ └──────────────────┘ └───────────────┘
What you'll build
A webhook receiver that:
- Accepts POST requests from any external service
- Verifies signatures (using Stripe as an example)
- Logs and stores events in Postgres
- Is reachable from the internet via
kindling expose
Project structure
webhook-app/
├── Dockerfile
├── requirements.txt
└── main.py
requirements.txt
fastapi==0.115.0
uvicorn[standard]==0.30.0
stripe==10.0.0
asyncpg==0.30.0
main.py
import json
import os
from contextlib import asynccontextmanager
import asyncpg
import stripe
from fastapi import FastAPI, Request, HTTPException
DATABASE_URL = os.environ["WEBHOOK_DB_URL"]
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
stripe.api_key = os.environ.get("STRIPE_KEY", "")
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=5)
async with app.state.pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS webhook_events (
id SERIAL PRIMARY KEY,
source TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
verified BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
)
""")
yield
await app.state.pool.close()
app = FastAPI(title="Webhook Receiver", lifespan=lifespan)
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
"""Receive and verify Stripe webhook events."""
payload = await request.body()
sig_header = request.headers.get("stripe-signature", "")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
raise HTTPException(400, "Invalid signature")
# Store the verified event
async with app.state.pool.acquire() as conn:
await conn.execute(
"INSERT INTO webhook_events (source, event_type, payload, verified) "
"VALUES ($1, $2, $3, $4)",
"stripe", event["type"], json.dumps(event), True,
)
print(f"Stripe event: {event['type']} ({event['id']})")
return {"received": True}
@app.post("/webhooks/github")
async def github_webhook(request: Request):
"""Receive GitHub webhook events (push, PR, issue, etc.)."""
payload = await request.json()
event_type = request.headers.get("X-GitHub-Event", "unknown")
async with app.state.pool.acquire() as conn:
await conn.execute(
"INSERT INTO webhook_events (source, event_type, payload) "
"VALUES ($1, $2, $3)",
"github", event_type, json.dumps(payload),
)
print(f"GitHub event: {event_type}")
return {"received": True}
@app.post("/webhooks/generic")
async def generic_webhook(request: Request):
"""Catch-all endpoint for any webhook source."""
payload = await request.json()
async with app.state.pool.acquire() as conn:
await conn.execute(
"INSERT INTO webhook_events (source, event_type, payload) "
"VALUES ($1, $2, $3)",
"generic", "incoming", json.dumps(payload),
)
print(f"Generic webhook received: {json.dumps(payload)[:100]}")
return {"received": True}
@app.get("/webhooks/events")
async def list_events(source: str = None, limit: int = 20):
"""List recent webhook events for debugging."""
pool = app.state.pool
if source:
rows = await pool.fetch(
"SELECT id, source, event_type, verified, created_at FROM webhook_events "
"WHERE source = $1 ORDER BY created_at DESC LIMIT $2",
source, limit,
)
else:
rows = await pool.fetch(
"SELECT id, source, event_type, verified, created_at FROM webhook_events "
"ORDER BY created_at DESC LIMIT $1",
limit,
)
return [dict(r) for r in rows]
@app.get("/health")
async def health():
return {"status": "ok"}
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
kindling setup
1. Store secrets
kindling secrets set STRIPE_KEY sk_test_xxxxx
kindling secrets set STRIPE_WEBHOOK_SECRET whsec_xxxxx
2. Workflow
name: dev-deploy
on:
push:
branches: [main]
workflow_dispatch:
env:
REGISTRY: registry:5000
TAG: ${{ github.actor }}-${{ github.sha }}
jobs:
deploy:
runs-on: [self-hosted, "${{ github.actor }}"]
steps:
- uses: actions/checkout@v4
- run: rm -rf /builds/*
- name: Build
uses: kindling-sh/kindling/.github/actions/kindling-build@main
with:
name: webhook-app
context: ${{ github.workspace }}
image: "${{ env.REGISTRY }}/webhook-app:${{ env.TAG }}"
- name: Deploy
uses: kindling-sh/kindling/.github/actions/kindling-deploy@main
with:
name: ${{ github.actor }}-webhook-app
image: "${{ env.REGISTRY }}/webhook-app:${{ env.TAG }}"
port: "8000"
ingress-host: "${{ github.actor }}-webhooks.localhost"
health-check-path: "/health"
dependencies:
- type: postgres
name: webhook-db
env: |
- name: STRIPE_KEY
valueFrom:
secretKeyRef:
name: kindling-secret-stripe-key
key: value
- name: STRIPE_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
name: kindling-secret-stripe-webhook-secret
key: value
3. Create a public tunnel
kindling expose <you>-webhook-app
# Output: https://abc123.trycloudflare.com
This gives you a public HTTPS URL that forwards to your local app.
4. Register the webhook URL
Stripe:
Go to Stripe Dashboard → Developers → Webhooks → Add Endpoint.
Use https://abc123.trycloudflare.com/webhooks/stripe.
GitHub:
Go to your repo → Settings → Webhooks → Add Webhook.
Use https://abc123.trycloudflare.com/webhooks/github.
Any service:
Use https://abc123.trycloudflare.com/webhooks/generic as the
catch-all endpoint.
5. Debug
# See what events came in
curl "http://<you>-webhooks.localhost/webhooks/events"
# Filter by source
curl "http://<you>-webhooks.localhost/webhooks/events?source=stripe"
# Watch logs in real time
kindling logs <you>-webhook-app -f
Iterate
kindling sync -n <you>-webhook-app -d .
# Add a new endpoint for Shopify webhooks, change event processing
# logic, add signature verification — tunnel stays connected
The tunnel URL remains stable across syncs, so you don't need to re-register the webhook endpoint with the external service.
Tips
- Replay events: use the Stripe CLI (
stripe trigger payment_intent.succeeded) or GitHub's "Redeliver" button to replay events during development - Multiple providers: add as many
/webhooks/<provider>endpoints as you need — they all share the same tunnel - The events table doubles as an audit log — query it to verify your app handled events correctly