Modern QA2026Webhook Testing — tiles
Log inJoin
66 / 89 · 04 API & Contract Testing with AI · Webhook and SQS/EventBridge Testing← prev⊞ allnext →☰ Read as one page

10.4Webhook Testing

Webhooks require a different approach: you spin up a temporary HTTP server, register it as a webhook target, trigger the event, and verify the webhook was called.

from http.server import HTTPServer, BaseHTTPRequestHandler
import threading
import json

class WebhookCapture:
    """Temporary HTTP server that captures webhook calls."""

    def __init__(self, port: int = 0):
        self.received = []
        self._server = None
        self._thread = None
        self.port = port

    def start(self):
        capture = self

        class Handler(BaseHTTPRequestHandler):
            def do_POST(self):
                content_length = int(self.headers.get('Content-Length', 0))
                body = self.rfile.read(content_length)
                capture.received.append({
                    "path": self.path,
                    "headers": dict(self.headers),
                    "body": json.loads(body) if body else None,
                    "timestamp": time.time(),
                })
                self.send_response(200)
                self.end_headers()

            def log_message(self, *args):
                pass  # Suppress request logging

        self._server = HTTPServer(("0.0.0.0", self.port), Handler)
        self.port = self._server.server_address[1]
        self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
        self._thread.start()
        return self

    def stop(self):
        if self._server:
            self._server.shutdown()

    def wait_for_call(self, timeout: int = 10) -> dict | None:
        deadline = time.time() + timeout
        while time.time() < deadline:
            if self.received:
                return self.received[-1]
            time.sleep(0.1)
        return None

    @property
    def url(self):
        return f"http://localhost:{self.port}"


class TestWebhookDelivery:
    """Test webhook delivery for order events."""

    @pytest.fixture
    def webhook_server(self):
        server = WebhookCapture().start()
        yield server
        server.stop()

    def test_order_webhook_delivered(self, api_client, webhook_server, admin_token):
        """Creating an order should trigger a webhook to registered URL."""
        # Register webhook
        api_client.post("/api/webhooks", json={
            "url": f"{webhook_server.url}/hooks/orders",
            "events": ["order.created"],
        }, headers={"Authorization": f"Bearer {admin_token}"})

        # Create an order (triggers the webhook)
        api_client.post("/api/orders", json={
            "items": [{"product_id": "prod-1", "quantity": 1}],
            "idempotency_key": "idem-1",
        }, headers={"Authorization": f"Bearer {admin_token}"})

        # Verify webhook was called
        call = webhook_server.wait_for_call(timeout=10)
        assert call is not None, "Webhook was not delivered within 10s"
        assert call["path"] == "/hooks/orders"
        assert call["body"]["event_type"] == "order.created"
        assert call["body"]["data"]["items"][0]["product_id"] == "prod-1"

    def test_webhook_retry_on_failure(self, api_client, admin_token):
        """Webhooks should retry when the target returns a 5xx error."""
        # Register webhook pointing to a non-existent server
        api_client.post("/api/webhooks", json={
            "url": "http://localhost:19999/will-fail",
            "events": ["order.created"],
        }, headers={"Authorization": f"Bearer {admin_token}"})

        # Create an order
        api_client.post("/api/orders", json={
            "items": [{"product_id": "prod-1", "quantity": 1}],
            "idempotency_key": "idem-retry",
        }, headers={"Authorization": f"Bearer {admin_token}"})

        # Check webhook delivery log shows retry attempts
        time.sleep(15)  # Wait for retry cycle
        log = api_client.get("/api/webhooks/delivery-log",
                           headers={"Authorization": f"Bearer {admin_token}"})
        deliveries = log.json()["deliveries"]
        failed = [d for d in deliveries if d["status"] == "failed"]
        assert len(failed) >= 2, "Expected at least 2 retry attempts"

    def test_webhook_signature_verification(self, webhook_server, api_client, admin_token):
        """Webhook payloads should include a signature header for verification."""
        api_client.post("/api/webhooks", json={
            "url": f"{webhook_server.url}/hooks/signed",
            "events": ["order.created"],
            "secret": "webhook-secret-123",
        }, headers={"Authorization": f"Bearer {admin_token}"})

        api_client.post("/api/orders", json={
            "items": [{"product_id": "prod-1", "quantity": 1}],
            "idempotency_key": "idem-signed",
        }, headers={"Authorization": f"Bearer {admin_token}"})

        call = webhook_server.wait_for_call(timeout=10)
        assert call is not None
        assert "X-Webhook-Signature" in call["headers"], (
            "Webhook signature header is missing"
        )