Skip to content

Webhook Failures Troubleshooting Guide

Webhook Failures Troubleshooting Guide

This guide helps diagnose why webhooks are not working. Follow the systematic debug process to identify and fix issues quickly.


Quick Diagnosis

Webhook not received at all? β†’ See Webhook Not Delivered Webhook received but signature fails? β†’ See Signature Validation Webhook times out? β†’ See Timeout Issues Webhook processed but business logic wrong? β†’ See Logic Errors Duplicate webhooks? β†’ See Duplicate Webhooks


Systematic Debug Process

Step 1: Verify External Service Configuration

PactaPay Dashboard Check:

  1. Login to https://dashboard.pactapay.com
  2. Navigate to Webhooks section
  3. Verify endpoint configured:
    • URL: https://YOUR_SUBDOMAIN.ngrok-free.app/api/commerce/webhooks/pactapay
    • Events: payment.completed, payment.failed, payment.refunded
    • Status: βœ… Active (not paused)

Test Webhook:

Terminal window
# In PactaPay dashboard, click "Send test event"
# Check delivery status:
# - βœ… 200: Success
# - ⚠️ 4xx/5xx: Error (check logs)
# - ⏱️ Timeout: Server not responding

Delivery Logs:

  • Dashboard β†’ Webhooks β†’ Click endpoint β†’ Delivery logs
  • Look for recent attempts and response codes
  • Failed deliveries show error details (DNS, connection, timeout)

Step 2: Verify ngrok Tunnel

Check tunnel running:

Terminal window
# Should show ngrok process
ps aux | grep ngrok
# If not running, start it
ngrok http 8000

Check tunnel URL:

Terminal window
# Visit ngrok web UI
open http://127.0.0.1:4040
# Or get URL programmatically
curl http://127.0.0.1:4040/api/tunnels | jq '.tunnels[0].public_url'

Match URL in PactaPay:

  • PactaPay webhook URL: https://abc123.ngrok-free.app/api/commerce/webhooks/pactapay
  • ngrok tunnel URL: https://abc123.ngrok-free.app
  • βœ… Should match exactly (ngrok URLs change on restart)

⚠️ Important: ngrok free URLs are not persistent. Every restart generates a new URL that must be updated in PactaPay dashboard.


Step 3: Verify Local Server

Server running:

Terminal window
# Check process
ps aux | grep "python.*manage.py"
# Or for uvicorn
ps aux | grep uvicorn
# Check port
lsof -i :8000
# Test health endpoint
curl http://localhost:8000/health
# Should return: {"status": "ok"}

Endpoint exists:

Terminal window
# Check route registered in OpenAPI docs
curl http://localhost:8000/docs | grep "webhooks"
# Test endpoint directly (local)
curl -X POST http://localhost:8000/api/commerce/webhooks/pactapay \
-H "Content-Type: application/json" \
-d '{"event":"test"}'
# Should return: 200 or 401 (signature validation)

Step 4: Check ngrok Request Inspector

Open Inspector:

Terminal window
open http://127.0.0.1:4040/inspect/http

Look for incoming requests:

  • βœ… If you see requests β†’ Tunnel working, check response status
  • ❌ If no requests β†’ Webhook not reaching ngrok (check PactaPay config)

Inspect request details:

  • URL: Should match your endpoint path exactly
  • Headers: Check X-Pactapay-Signature present
  • Body: Should be valid JSON
  • Response: Check status code
    • 200 = Success
    • 401 = Signature validation failed
    • 500 = Server error
    • Timeout = Handler exceeded 10 seconds

Replay requests:

  • Click on a past request
  • Click Replay to resend identical webhook
  • Edit request body/headers if needed for testing

Step 5: Check Server Logs

View logs:

Terminal window
# Docker
docker logs backend -f
# Local
tail -f logs/app.log
# Look for:
# - "Webhook received" (confirms request arrived)
# - "Invalid signature" (signature validation failed)
# - "Exception" or "ERROR" (handler crashed)

Common log patterns:

Pattern 1: No logs

# No webhook-related logs at all
β†’ Problem: Request not reaching server
β†’ Check: ngrok tunnel, endpoint path, server running

Pattern 2: Signature validation fails

ERROR: Invalid webhook signature
β†’ Problem: Signature mismatch
β†’ Check: PACTAPAY_WEBHOOK_SECRET matches dashboard

Pattern 3: Exception in handler

ERROR: Exception in webhook handler: KeyError: 'payment_id'
β†’ Problem: Business logic crashed
β†’ Check: Handler code, database connection, missing fields

Common Issues & Solutions

Issue 1: Webhook Not Delivered

Symptoms:

  • No requests in ngrok inspector
  • No logs in server
  • PactaPay shows β€œDelivery failed”

Diagnosis:

Terminal window
# Check PactaPay delivery logs
# Dashboard β†’ Webhooks β†’ Click on endpoint β†’ Delivery logs
# Common errors:
# - DNS resolution failed
# - Connection refused
# - Connection timeout

Solutions:

A. ngrok URL changed

Terminal window
# Get current URL
curl http://127.0.0.1:4040/api/tunnels | jq '.tunnels[0].public_url'
# Update in PactaPay dashboard
# Webhooks β†’ Edit endpoint β†’ Update URL

B. ngrok tunnel closed

Terminal window
# Restart tunnel
pkill ngrok
ngrok http 8000
# Copy new URL and update in PactaPay dashboard

C. Firewall blocking

Terminal window
# Check firewall status (macOS)
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
# Allow ngrok through firewall
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add /usr/local/bin/ngrok

D. Server not reachable

Terminal window
# Verify server responds to ngrok
curl https://YOUR_SUBDOMAIN.ngrok-free.app/health
# If timeout or connection refused:
# - Check server is running (lsof -i :8000)
# - Check ngrok is forwarding to correct port
# - Restart both server and ngrok

Issue 2: Signature Validation Fails

Symptoms:

  • Webhook arrives (seen in ngrok inspector)
  • Server returns 401 Unauthorized
  • Logs show β€œInvalid signature” or β€œSignature verification failed”

Diagnosis:

Terminal window
# Check webhook secret configured
echo $PACTAPAY_WEBHOOK_SECRET
# Check .env file
cat .env | grep WEBHOOK_SECRET
# Verify matches PactaPay dashboard
# Dashboard β†’ Webhooks β†’ Click endpoint β†’ "Signing secret"

Solutions:

A. Secret mismatch

Terminal window
# Copy secret from PactaPay dashboard
# Update .env
echo "PACTAPAY_WEBHOOK_SECRET=whsec_xyz789" >> .env
# Restart server to reload environment
docker-compose restart backend

B. Secret not loaded

# Verify environment variable loading in your app
from dotenv import load_dotenv
import os
load_dotenv()
# Debug: Print secret (remove in production!)
print(f"Webhook secret: {os.getenv('PACTAPAY_WEBHOOK_SECRET')}")
# Should NOT be None

C. Signature algorithm wrong

# PactaPay uses HMAC-SHA256
import hmac
import hashlib
def verify_signature(payload: str, signature: str, secret: str) -> bool:
"""Verify webhook signature using HMAC-SHA256"""
expected = hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
# Compare with constant-time comparison to prevent timing attacks
return hmac.compare_digest(signature, f"sha256={expected}")
# ❌ Common mistake: Using MD5 or SHA1 instead of SHA256

D. Payload modified before validation

# ❌ WRONG: Parsing JSON before validation
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
data = await request.json() # This modifies payload!
signature = request.headers.get("X-Pactapay-Signature")
verify_signature(request.body, signature, secret) # FAILS!
# βœ… CORRECT: Use raw body for validation
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
raw_body = await request.body()
signature = request.headers.get("X-Pactapay-Signature")
# Verify signature on raw bytes
verify_signature(raw_body.decode(), signature, secret)
# Parse JSON only after verification
data = json.loads(raw_body)

E. Signature header format mismatch

# Check exact header format
# PactaPay sends: X-Pactapay-Signature: sha256=abc123...
# Stripe sends: Stripe-Signature: t=timestamp,v1=signature
# Extract signature correctly:
signature_header = request.headers.get("X-Pactapay-Signature")
# If format is "sha256=abc123", extract the hash part
if signature_header.startswith("sha256="):
signature = signature_header.split("=", 1)[1]
else:
signature = signature_header

Issue 3: Webhook Timeout

Symptoms:

  • PactaPay shows β€œTimeout after 10s”
  • ngrok shows request started but no response
  • Server logs show request received but no β€œCompleted” log

Cause: Webhook handlers must respond within 10 seconds. Long-running operations cause timeouts.

Diagnosis:

Terminal window
# Add timing logs to your handler
# logger.info("Webhook handler started")
# ... processing ...
# logger.info("Webhook handler completed")
# If "completed" never appears β†’ Handler is hanging

Solutions:

A. Long-running operation in handler

# ❌ WRONG: Blocking operation in handler
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
payment = process_payment(request)
send_confirmation_email(payment) # Takes 5 seconds!
generate_pdf_invoice(payment) # Takes 3 seconds!
return {"received": True} # Timeout!
# βœ… CORRECT: Use background tasks
from fastapi import BackgroundTasks
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request, bg: BackgroundTasks):
payment = process_payment(request) # Fast operation
# Queue slow operations as background tasks
bg.add_task(send_confirmation_email, payment)
bg.add_task(generate_pdf_invoice, payment)
return {"received": True} # Return immediately (under 1s)

B. Database deadlock or slow query

Terminal window
# Check for long-running database queries
psql $DATABASE_URL -c "SELECT * FROM pg_stat_activity WHERE state = 'active' AND query_start < NOW() - INTERVAL '5 seconds';"
# Kill deadlocked queries
psql $DATABASE_URL -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'active' AND query_start < NOW() - INTERVAL '10 seconds';"
# Add query timeout to database connections
from sqlalchemy import create_engine
engine = create_engine(
DATABASE_URL,
connect_args={
"options": "-c statement_timeout=5000" # 5 second timeout
}
)

C. External API call timeout

# Add timeout to external HTTP calls
import httpx
async def fetch_payment_details(payment_id: str):
async with httpx.AsyncClient(timeout=3.0) as client: # 3 second timeout
try:
response = await client.get(f"https://api.pactapay.com/payments/{payment_id}")
return response.json()
except httpx.TimeoutException:
logger.error(f"Payment API timeout for {payment_id}")
return None # Handle gracefully

D. Synchronous operation causing blocking

# ❌ WRONG: Synchronous blocking operation
import time
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
time.sleep(15) # Blocks async event loop!
return {"received": True}
# βœ… CORRECT: Use async operations
import asyncio
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
await asyncio.sleep(1) # Non-blocking
return {"received": True}

Issue 4: Duplicate Webhooks

Symptoms:

  • Payment processed twice
  • Emails sent multiple times
  • Multiple database records for same event
  • Multiple logs for same webhook ID

Cause: PactaPay retries failed webhooks (up to 3 times with exponential backoff) if your server:

  • Returns non-2xx status code
  • Times out
  • Has connection error

Solutions:

A. Idempotency checks with in-memory storage

# Simple in-memory deduplication (single process only)
processed_webhooks = set()
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
data = await request.json()
webhook_id = data['id'] # PactaPay sends unique 'id' field
# Check if already processed
if webhook_id in processed_webhooks:
logger.info(f"Duplicate webhook ignored: {webhook_id}")
return {"received": True, "status": "duplicate"}
# Process webhook
process_payment(data)
# Mark as processed
processed_webhooks.add(webhook_id)
return {"received": True, "status": "processed"}

B. Database-backed idempotency (production-ready)

# Create table for webhook tracking
# CREATE TABLE processed_webhooks (
# webhook_id VARCHAR(255) PRIMARY KEY,
# processed_at TIMESTAMP DEFAULT NOW()
# );
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request, db: Session = Depends(get_db)):
data = await request.json()
webhook_id = data['id']
# Check if webhook already processed (atomic)
existing = db.query(ProcessedWebhook).filter_by(webhook_id=webhook_id).first()
if existing:
logger.info(f"Duplicate webhook: {webhook_id} (processed at {existing.processed_at})")
return {"received": True, "status": "duplicate"}
# Process webhook
try:
process_payment(data)
# Record as processed
db.add(ProcessedWebhook(webhook_id=webhook_id))
db.commit()
return {"received": True, "status": "processed"}
except Exception as e:
db.rollback()
logger.error(f"Webhook processing failed: {e}")
raise

C. Database unique constraints

# Ensure payment_id is unique to prevent duplicates
from sqlalchemy import Column, String, Integer, UniqueConstraint
class Payment(Base):
__tablename__ = "payments"
id = Column(Integer, primary_key=True)
payment_id = Column(String, unique=True, index=True, nullable=False) # Unique constraint
amount = Column(Integer, nullable=False)
status = Column(String, nullable=False)
# On duplicate insert, catch exception
from sqlalchemy.exc import IntegrityError
try:
payment = Payment(payment_id=data['payment_id'], amount=data['amount'])
db.add(payment)
db.commit()
except IntegrityError:
db.rollback()
logger.warning(f"Duplicate payment prevented: {data['payment_id']}")
return {"received": True, "status": "duplicate"}

D. Redis-based idempotency (distributed systems)

import redis
redis_client = redis.Redis(host='localhost', port=6379, db=0)
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
data = await request.json()
webhook_id = data['id']
# Try to set webhook ID with expiration (24 hours)
# Returns True only if key doesn't exist (NX flag)
is_new = redis_client.set(
f"webhook:{webhook_id}",
"processed",
ex=86400, # Expire after 24 hours
nx=True # Only set if not exists
)
if not is_new:
logger.info(f"Duplicate webhook: {webhook_id}")
return {"received": True, "status": "duplicate"}
# Process webhook
process_payment(data)
return {"received": True, "status": "processed"}

Issue 5: Business Logic Errors

Symptoms:

  • Webhook processed successfully (200 OK)
  • Signature validation passes
  • But payment status incorrect or data missing

Solutions:

A. Missing or null fields

# Add defensive field validation
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
data = await request.json()
# Validate required fields
required_fields = ['id', 'event', 'payment_id', 'amount']
missing = [f for f in required_fields if f not in data]
if missing:
logger.error(f"Webhook missing fields: {missing}")
return {"received": True, "error": f"Missing fields: {missing}"}
# Validate types
if not isinstance(data['amount'], int) or data['amount'] <= 0:
logger.error(f"Invalid amount: {data['amount']}")
return {"received": True, "error": "Invalid amount"}
# Process only after validation
process_payment(data)

B. Event type handling

# Handle all event types explicitly
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
data = await request.json()
event_type = data.get('event')
if event_type == 'payment.completed':
handle_payment_completed(data)
elif event_type == 'payment.failed':
handle_payment_failed(data)
elif event_type == 'payment.refunded':
handle_payment_refunded(data)
else:
logger.warning(f"Unknown event type: {event_type}")
return {"received": True, "warning": f"Unknown event: {event_type}"}
return {"received": True, "status": "processed"}

Testing Webhooks Locally

Manual Test (No Signature)

Terminal window
# Send test webhook to local server
curl -X POST http://localhost:8000/api/commerce/webhooks/pactapay \
-H "Content-Type: application/json" \
-H "X-Pactapay-Signature: sha256=test_signature" \
-d '{
"id": "evt_test123",
"event": "payment.completed",
"payment_id": "pay_test123",
"amount": 5000,
"currency": "USD"
}'

With Valid Signature (Python)

#!/usr/bin/env python3
"""Generate valid webhook signature for testing"""
import hmac
import hashlib
import json
# Your webhook payload
payload = {
"id": "evt_test123",
"event": "payment.completed",
"payment_id": "pay_test123",
"amount": 5000
}
# Serialize to JSON (no spaces, sorted keys for consistency)
payload_str = json.dumps(payload, separators=(',', ':'), sort_keys=True)
# Your webhook secret from .env
secret = "whsec_your_secret_here"
# Compute HMAC-SHA256 signature
signature = hmac.new(
secret.encode(),
payload_str.encode(),
hashlib.sha256
).hexdigest()
print(f"Payload: {payload_str}")
print(f"Signature: sha256={signature}")
print(f"\nCurl command:")
print(f"""
curl -X POST http://localhost:8000/api/commerce/webhooks/pactapay \\
-H "Content-Type: application/json" \\
-H "X-Pactapay-Signature: sha256={signature}" \\
-d '{payload_str}'
""")

With Valid Signature (Bash)

generate_webhook_signature.sh
#!/bin/bash
PAYLOAD='{"id":"evt_test","event":"payment.completed","payment_id":"pay_test"}'
SECRET="whsec_your_secret"
# Compute HMAC-SHA256 signature
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
echo "X-Pactapay-Signature: sha256=$SIGNATURE"
echo ""
echo "Curl command:"
echo "curl -X POST http://localhost:8000/api/commerce/webhooks/pactapay \\"
echo " -H 'Content-Type: application/json' \\"
echo " -H 'X-Pactapay-Signature: sha256=$SIGNATURE' \\"
echo " -d '$PAYLOAD'"

Using ngrok Inspector

  1. Open http://127.0.0.1:4040/inspect/http
  2. Find a past webhook request
  3. Click Replay to resend identical request
  4. Edit request body/headers if needed
  5. Click Replay again to test

Advantages:

  • Uses real PactaPay webhook format
  • Includes correct signature
  • Can modify payload for testing edge cases

Security Best Practices

1. Always Verify Signatures

# NEVER skip signature validation, even in development
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
# ❌ NEVER DO THIS
# if os.getenv("ENV") == "development":
# # Skip validation in dev
# pass
# βœ… ALWAYS validate
raw_body = await request.body()
signature = request.headers.get("X-Pactapay-Signature")
secret = os.getenv("PACTAPAY_WEBHOOK_SECRET")
if not verify_signature(raw_body.decode(), signature, secret):
raise HTTPException(status_code=401, detail="Invalid signature")
# Process webhook...

2. Use Constant-Time Comparison

import hmac
# βœ… CORRECT: Prevents timing attacks
def verify_signature(payload: str, signature: str, secret: str) -> bool:
expected = compute_signature(payload, secret)
return hmac.compare_digest(signature, expected)
# ❌ WRONG: Vulnerable to timing attacks
def verify_signature_unsafe(payload: str, signature: str, secret: str) -> bool:
expected = compute_signature(payload, secret)
return signature == expected # Leaks timing information!

3. Validate Timestamp (Replay Attack Prevention)

import time
from datetime import datetime, timedelta
@router.post("/webhooks/pactapay")
async def handle_webhook(request: Request):
data = await request.json()
# Check timestamp if provided
if 'timestamp' in data:
webhook_time = datetime.fromisoformat(data['timestamp'])
age = datetime.utcnow() - webhook_time
# Reject webhooks older than 5 minutes
if age > timedelta(minutes=5):
logger.warning(f"Webhook too old: {age}")
raise HTTPException(status_code=400, detail="Webhook expired")

4. Rate Limiting

from fastapi_limiter import FastAPILimiter
from fastapi_limiter.depends import RateLimiter
@router.post(
"/webhooks/pactapay",
dependencies=[Depends(RateLimiter(times=100, seconds=60))] # 100 req/min
)
async def handle_webhook(request: Request):
# Process webhook...
pass

Debug Checklist

When webhook fails, verify:

  1. βœ… PactaPay webhook endpoint configured correctly
  2. βœ… ngrok tunnel running (ps aux | grep ngrok)
  3. βœ… ngrok URL matches PactaPay config
  4. βœ… Local server running (lsof -i :8000)
  5. βœ… Endpoint exists (curl http://localhost:8000/docs)
  6. βœ… ngrok shows incoming request (http://127.0.0.1:4040)
  7. βœ… Server logs show request received
  8. βœ… Signature validation passes
  9. βœ… Handler returns within 10 seconds
  10. βœ… Response status 200

Automated Debug Script

Save as webhook_debug.sh:

#!/bin/bash
# Automated webhook debugging script
echo "=== Webhook Debug Report ==="
echo ""
echo "Generated: $(date)"
echo ""
echo "1. ngrok tunnel status:"
if pgrep -f ngrok > /dev/null; then
echo " βœ… ngrok is running"
TUNNEL_URL=$(curl -s http://127.0.0.1:4040/api/tunnels | jq -r '.tunnels[0].public_url // "Not available"')
echo " URL: $TUNNEL_URL"
else
echo " ❌ ngrok is NOT running"
fi
echo ""
echo "2. Local server status:"
if lsof -i :8000 > /dev/null 2>&1; then
echo " βœ… Server is running on port 8000"
HEALTH=$(curl -s http://localhost:8000/health || echo "Health check failed")
echo " Health: $HEALTH"
else
echo " ❌ No server running on port 8000"
fi
echo ""
echo "3. Environment variables:"
if [ -f .env ]; then
echo " βœ… .env file exists"
if grep -q "PACTAPAY_WEBHOOK_SECRET" .env; then
echo " βœ… PACTAPAY_WEBHOOK_SECRET configured"
else
echo " ❌ PACTAPAY_WEBHOOK_SECRET not found in .env"
fi
else
echo " ⚠️ .env file not found"
fi
echo ""
echo "4. Recent server logs (last 20 lines):"
if docker ps | grep -q backend; then
docker logs backend 2>&1 | tail -20
else
echo " ⚠️ Docker container 'backend' not running"
fi
echo ""
echo "5. ngrok recent requests (last 3):"
curl -s http://127.0.0.1:4040/api/requests/http | jq -r '.requests[0:3] | .[] | "\(.StartTime) \(.Method) \(.Uri) -> \(.Response.StatusCode)"' 2>/dev/null || echo " ⚠️ No requests in ngrok inspector"
echo ""
echo "=== Debug report complete ==="

Run with:

Terminal window
chmod +x webhook_debug.sh
./webhook_debug.sh

Getting Help

If webhook still fails after debugging:

  1. Collect diagnostics:

    Terminal window
    ./webhook_debug.sh > webhook-debug.log
  2. Export ngrok request:

  3. Collect server logs:

    Terminal window
    docker logs backend > backend.log 2>&1
  4. Create GitHub issue with:

    • webhook-debug.log
    • cURL export from ngrok
    • backend.log
    • Description of expected vs actual behavior


Appendix: Webhook Flow Diagram

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PactaPay β”‚
β”‚ Service β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”‚ POST /api/commerce/webhooks/pactapay
β”‚ Headers: X-Pactapay-Signature
β”‚ Body: {"event": "payment.completed", ...}
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ ngrok β”‚
β”‚ Tunnel β”‚ https://abc123.ngrok-free.app
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”‚ Forwards to localhost:8000
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ FastAPI β”‚
β”‚ Server β”‚ localhost:8000
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”‚ 1. Read raw request body
β”‚ 2. Verify HMAC-SHA256 signature
β”‚ 3. Parse JSON payload
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Webhook β”‚
β”‚ Handler β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”‚ 4. Validate event type
β”‚ 5. Check idempotency
β”‚ 6. Update database
β”‚ 7. Queue background tasks
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Response β”‚
β”‚ 200 OK β”‚ {"received": true}
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Last Updated: February 2026 Version: 1.0 Maintainer: Seed & Source Team