Webhooks

Webhook reception, signature verification, and reliability patterns.

Webhook Fundamentals

Webhook vs polling — push vs pull
Polling:  Client repeatedly asks "anything new?" every N seconds
          GET /v1/events?since=2026-04-10T00:00:00Z (wasteful, delayed)

Webhook:  Server POSTs to client URL when event occurs
          POST https://your-app.example.com/webhooks/events (instant, efficient)

Receiving Webhooks

FastAPI webhook receiver — domus-api pattern
from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib

app = FastAPI()

@app.post("/webhooks/github")
async def github_webhook(request: Request):
    body = await request.body()
    signature = request.headers.get("X-Hub-Signature-256", "")

    # Verify signature
    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET.encode(), body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(signature, expected):
        raise HTTPException(status_code=401, detail="Invalid signature")

    payload = await request.json()
    event = request.headers.get("X-GitHub-Event")
    print(f"Received {event}: {payload.get('action', 'N/A')}")
    return {"status": "ok"}
Minimal webhook receiver with netcat — quick debugging
# Listen on port 9999, print incoming webhook payload
while true; do
    echo -e "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK" | \
      nc -l -p 9999 | tee -a webhook_log.txt
    echo "---" >> webhook_log.txt
done

Webhook Signature Verification

HMAC-SHA256 verification — GitHub style
# Verify a GitHub webhook payload
body='{"action":"push","ref":"refs/heads/main"}'
secret="whsec_abc123"
expected=$(echo -n "$body" | openssl dgst -sha256 -hmac "$secret" | awk '{print $2}')
received="abc123def456..."  # from X-Hub-Signature-256 header

if [[ "sha256=$expected" == "$received" ]]; then
    echo "Signature valid"
else
    echo "Signature INVALID -- reject" >&2
fi
Always use constant-time comparison (hmac.compare_digest in Python, not ==) to prevent timing attacks.
Meraki webhook payload structure
{
  "version": "0.1",
  "sharedSecret": "secret123",
  "sentAt": "2026-04-10T12:00:00Z",
  "organizationId": "12345",
  "organizationName": "DomusDigitalis",
  "networkId": "L_123",
  "alertType": "Motion detected",
  "alertData": {}
}

Webhook Registration

GitHub — create webhook via gh CLI
gh api repos/owner/repo/hooks \
  -f 'name=web' \
  -f 'config[url]=https://your-app.example.com/webhooks/github' \
  -f 'config[content_type]=json' \
  -f 'config[secret]=your-webhook-secret' \
  -f 'events[]=push' \
  -f 'events[]=pull_request' \
  -f 'active=true'
List existing webhooks
gh api repos/owner/repo/hooks --jq '.[] | {id, url: .config.url, events, active}'
Test a webhook — trigger delivery manually
gh api repos/owner/repo/hooks/HOOK_ID/pings -X POST

Webhook Reliability

Respond quickly — return 200 before processing
from fastapi import BackgroundTasks

@app.post("/webhooks/events")
async def receive_event(request: Request, bg: BackgroundTasks):
    payload = await request.json()
    bg.add_task(process_event, payload)  # async processing
    return {"status": "accepted"}        # return 200 immediately
Retry behavior expectations
Provider        Retry Policy
GitHub          3 retries over ~1 hour, exponential backoff
Meraki          Unknown retries, shared secret for verification
Custom          Implement your own: queue failed deliveries, retry with backoff
Idempotent processing — handle duplicate deliveries
processed_ids: set[str] = set()  # use Redis/DB in production

@app.post("/webhooks/events")
async def receive_event(request: Request):
    payload = await request.json()
    event_id = request.headers.get("X-Delivery-ID", "")

    if event_id in processed_ids:
        return {"status": "already processed"}

    processed_ids.add(event_id)
    await process_event(payload)
    return {"status": "ok"}

Local Development

Expose local webhook endpoint with ngrok or Tailscale Funnel
# ngrok -- tunnel to local FastAPI
ngrok http 8000
# Copy the https://xxx.ngrok.io URL to webhook config

# Tailscale Funnel -- if on Tailscale network
tailscale funnel 8000