{"repo":"NousResearch/hermes-agent","public_id":"20478","display_label":"Webhook signature validation support across multiple providers","summary":"These issues and pull requests concern adding and improving webhook signature validation for various providers and formats, including Svix, Fireflies V2, GitLab, and Standard Webhooks, as well as making signature and event type headers configurable per route.","subsystem":"gateway","counts":{"members":13,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":2,"merged_prs":2,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30200},"resolution":{"state":"partially_resolved","by":"pr-30200,pr-104183"},"freshness":{"state":"verified","last_verified_at":"2026-09-06T12:35:48+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1292307692307692},"fix_edges":[{"issue_number":20478,"pr_number":30200,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":47451,"pr_number":104183,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":101837,"pr_number":104183,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":20478,"title":"[Feature]: Support for more webhook signatures","url":null,"state":"closed"},{"kind":"issue","number":43575,"title":"[Bug]: Webhook signature validation doesn't support Fireflies V2 (X-Hub-Signature header)","url":null,"state":"open"},{"kind":"issue","number":47451,"title":"[Feature]: Support GitLab Standard Webhooks signing token (`webhook-signature`)","url":null,"state":"closed"},{"kind":"issue","number":68768,"title":"[Feature]: Configurable webhook signature and event type headers per route","url":null,"state":"open"},{"kind":"pr","number":20518,"title":"fix(webhook): support Svix signatures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"bbf02c322443b7e833d730bbfa1dbd5d246e71e2","number":0},{"kind":"pr","value":"#30200","number":30200},{"kind":"pr","value":"#30200","number":30200},{"kind":"pr","value":"#20478","number":20478}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":30200,"title":"fix(gateway): validate Svix webhook signatures","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/webhook.py","tests/gateway/test_webhook_adapter.py"],"fix_hunks":[{"file":"gateway/platforms/webhook.py","hunks":["@@ -27,6 +27,8 @@\n \"\"\"\n \n import asyncio\n+import base64\n+import binascii\n import hashlib\n import hmac\n import json","@@ -393,6 +395,7 @@ async def _handle_webhook(self, request: \"web.Request\") -> \"web.Response\":\n             request.headers.get(\"X-GitHub-Event\", \"\")\n             or request.headers.get(\"X-GitLab-Event\", \"\")\n             or payload.get(\"event_type\", \"\")\n+            or payload.get(\"type\", \"\")\n             or \"unknown\"\n         )\n         allowed_events = route_config.get(\"events\", [])","@@ -445,7 +448,10 @@ async def _handle_webhook(self, request: \"web.Request\") -> \"web.Response\":\n         # Build a unique delivery ID\n         delivery_id = request.headers.get(\n             \"X-GitHub-Delivery\",\n-            request.headers.get(\"X-Request-ID\", str(int(time.time() * 1000))),\n+            request.headers.get(\n+                \"svix-id\",\n+                request.headers.get(\"X-Request-ID\", str(int(time.time() * 1000))),\n+            ),\n         )\n \n         # ── Idempotency ─────────────────────────────────────────","@@ -590,7 +596,32 @@ async def _handle_webhook(self, request: \"web.Request\") -> \"web.Response\":\n     def _validate_signature(\n         self, request: \"web.Request\", body: bytes, secret: str\n     ) -> bool:\n-        \"\"\"Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256).\"\"\"\n+        \"\"\"Validate webhook signature (GitHub, GitLab, Svix, generic HMAC-SHA256).\"\"\"\n+        def _header(name: str) -> str:\n+            return (\n+                request.headers.get(name, \"\")\n+                or request.headers.get(name.lower(), \"\")\n+                or request.headers.get(name.upper(), \"\")\n+            )\n+\n+        # Svix / AgentMail:\n+        #   svix-id: msg_...\n+        #   svix-timestamp: unix seconds\n+        #   svix-signature: v1,<base64-hmac> [v1,<base64-hmac> ...]\n+        # Signed content is: \"{id}.{timestamp}.{raw_body}\".  Svix secrets\n+        # usually start with \"whsec_\" and the remainder is base64-encoded.\n+        svix_id = _header(\"svix-id\")\n+        svix_timestamp = _header(\"svix-timestamp\")\n+        svix_signature = _header(\"svix-signature\")\n+        if svix_id or svix_timestamp or svix_signature:\n+            return self._validate_svix_signature(\n+                body=body,\n+                secret=[redacted:secret-assignment]\n+                msg_id=svix_id,\n+                timestamp=svix_timestamp,\n+                signature_header=svix_signature,\n+            )\n+\n         # GitHub: X-Hub-Signature-256 = sha256=<hex>\n         gh_sig = request.headers.get(\"X-Hub-Signature-256\", \"\")\n         if gh_sig:","@@ -618,6 +649,56 @@ def _validate_signature(\n         )\n         return False\n \n+    def _validate_svix_signature(\n+        self,\n+        body: bytes,\n+        secret: str,\n+        msg_id: str,\n+        timestamp: str,\n+        signature_header: str,\n+        tolerance_seconds: int = 300,\n+    ) -> bool:\n+        \"\"\"Validate Svix-compatible signatures used by AgentMail webhooks.\"\"\"\n+        if not (msg_id and timestamp and signature_header and secret):\n+            return False\n+\n+        try:\n+            ts = int(timestamp)\n+        except (TypeError, ValueError):\n+            return False\n+        if abs(int(time.time()) - ts) > tolerance_seconds:\n+            logger.warning(\"[webhook] Svix signature timestamp outside replay window\")\n+            return False\n+\n+        if secret.startswith(\"whsec_\"):\n+            encoded_secret = [redacted:secret-assignment]\"whsec_\")\n+            try:\n+                key = base64.b64decode(encoded_secret, validate=True)\n+            except (binascii.Error, ValueError):\n+                logger.debug(\"[webhook] Invalid whsec_ Svix signing secret\")\n+                return False\n+        else:\n+            # Be permissive for providers that document Svix-style headers but\n+            # hand out raw shared secrets rather than whsec_ base64 secrets.\n+            logger.debug(\"[webhook] Validating Svix-style signature with raw secret\")\n+            key = secret.encode()\n+\n+        signed_content = msg_id.encode() + b\".\" + timestamp.encode() + b\".\" + body\n+        expected = base64.b64encode(\n+            hmac.new(key, signed_content, hashlib.sha256).digest()\n+        ).decode()\n+\n+        # Svix can send multiple signatures separated by spaces during secret\n+        # rotation. Each entry is formatted as \"vN,<base64>\".\n+        for part in signature_header.split():\n+            try:\n+                version, signature = part.split(\",\", 1)\n+            except ValueError:\n+                continue\n+            if version == \"v1\" and hmac.compare_digest(signature, expected):\n+                return True\n+        return False\n+\n     # ------------------------------------------------------------------\n     # Prompt rendering\n     # ------------------------------------------------------------------"]},{"file":"tests/gateway/test_webhook_adapter.py","hunks":["@@ -15,6 +15,7 @@\n \"\"\"\n \n import asyncio\n+import base64\n import hashlib\n import hmac\n import json","@@ -100,6 +101,18 @@ def _generic_signature(body: bytes, secret: str) -> str:\n     return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()\n \n \n+def _svix_signature(body: bytes, secret: str, msg_id: str, timestamp: str) -> str:\n+    \"\"\"Compute a Svix v1 signature header for *body* using *secret*.\"\"\"\n+    key = (\n+        base64.b64decode(secret.removeprefix(\"whsec_\"))\n+        if secret.startswith(\"whsec_\")\n+        else secret.encode()\n+    )\n+    signed = msg_id.encode() + b\".\" + timestamp.encode() + b\".\" + body\n+    digest = hmac.new(key, signed, hashlib.sha256).digest()\n+    return \"v1,\" + base64.b64encode(digest).decode()\n+\n+\n # ===================================================================\n # Signature validation\n # ===================================================================","@@ -170,6 +183,134 @@ def test_validate_generic_signature_valid(self):\n         req = _mock_request(headers={\"X-Webhook-Signature\": sig})\n         assert adapter._validate_signature(req, body, secret) is True\n \n+    def test_validate_svix_signature_valid(self):\n+        \"\"\"Valid Svix/AgentMail v1 signature headers are accepted.\"\"\"\n+        adapter = _make_adapter()\n+        body = b'{\"event_type\":\"message.received\"}'\n+        secret = \"[redacted:secret-assignment]\" + base64.b64encode(b\"agentmail-signing-secret\").decode()\n+        msg_id = \"msg_123\"\n+        timestamp = str(int(time.time()))\n+        sig = _svix_signature(body, secret, msg_id, timestamp)\n+        req = _mock_request(\n+            headers={\n+                \"svix-id\": msg_id,\n+                \"svix-timestamp\": timestamp,\n+                \"svix-signature\": sig,\n+            }\n+        )\n+        assert adapter._validate_signature(req, body, secret) is True\n+\n+    def test_validate_svix_signature_wrong_body_rejects(self):\n+        \"\"\"Svix/AgentMail signatures are bound to the exact raw request body.\"\"\"\n+        adapter = _make_adapter()\n+        signed_body = b'{\"event_type\":\"message.received\"}'\n+        received_body = b'{\"event_type\":\"message.sent\"}'\n+        secret = \"[redacted:secret-assignment]\" + base64.b64encode(b\"agentmail-signing-secret\").decode()\n+        msg_id = \"msg_123\"\n+        timestamp = str(int(time.time()))\n+        sig = _svix_signature(signed_body, secret, msg_id, timestamp)\n+        req = _mock_request(\n+            headers={\n+                \"svix-id\": msg_id,\n+                \"svix-timestamp\": timestamp,\n+                \"svix-signature\": sig,\n+            }\n+        )\n+        assert adapter._validate_signature(req, received_body, secret) is False\n+\n+    def test_validate_svix_signature_old_timestamp_rejects(self):\n+        \"\"\"Svix/AgentMail signatures outside the replay window are rejected.\"\"\"\n+        adapter = _make_adapter()\n+        body = b'{\"event_type\":\"message.received\"}'\n+        secret = \"[redacted:secret-assignment]\" + base64.b64encode(b\"agentmail-signing-secret\").decode()\n+        msg_id = \"msg_123\"\n+        timestamp = str(int(time.time()) - 301)\n+        sig = _svix_signature(body, secret, msg_id, timestamp)\n+        req = _mock_request(\n+            headers={\n+                \"svix-id\": msg_id,\n+                \"svix-timestamp\": timestamp,\n+                \"svix-signature\": sig,\n+            }\n+        )\n+        assert adapter._validate_signature(req, body, secret) is False\n+\n+    def test_validate_svix_signature_multiple_entries_accepts_matching_v1(self):\n+        \"\"\"Svix rotation headers may contain multiple space-separated signatures.\"\"\"\n+        adapter = _make_adapter()\n+        body = b'{\"event_type\":\"message.received\"}'\n+        secret = \"[redacted:secret-assignment]\" + base64.b64encode(b\"agentmail-signing-secret\").decode()\n+        msg_id = \"msg_123\"\n+        timestamp = str(int(time.time()))\n+        sig = _svix_signature(body, secret, msg_id, timestamp)\n+        req = _mock_request(\n+            headers={\n+                \"svix-id\": msg_id,\n+                \"svix-timestamp\": timestamp,\n+                \"svix-signature\": \"v1,wrong \" + sig,\n+            }\n+        )\n+        assert adapter._validate_signature(req, body, secret) is True\n+\n+    def test_validate_svix_signature_missing_signature_rejects(self):\n+        \"\"\"Partial Svix headers reject instead of falling through to another scheme.\"\"\"\n+        adapter = _make_adapter()\n+        req = _mock_request(headers={\"svix-id\": \"msg_123\"})\n+        assert adapter._validate_signature(req, b\"{}\", \"secret\") is False\n+\n+    def test_validate_svix_signature_unsupported_version_rejects(self):\n+        \"\"\"Only Svix v1 signatures are accepted.\"\"\"\n+        adapter = _make_adapter()\n+        body = b'{\"event_type\":\"message.received\"}'\n+        secret = \"[redacted:secret-assignment]\" + base64.b64encode(b\"agentmail-signing-secret\").decode()\n+        msg_id = \"msg_123\"\n+        timestamp = str(int(time.time()))\n+        sig = _svix_signature(body, secret, msg_id, timestamp).replace(\"v1,\", \"v2,\")\n+        req = _mock_request(\n+            headers={\n+                \"svix-id\": msg_id,\n+                \"svix-timestamp\": timestamp,\n+                \"svix-signature\": sig,\n+            }\n+        )\n+        assert adapter._validate_signature(req, body, secret) is False\n+\n+    def test_validate_svix_signature_invalid_whsec_rejects(self):\n+        \"\"\"Malformed whsec_ secrets are rejected, not silently treated as raw secrets.\"\"\"\n+        adapter = _make_adapter()\n+        body = b'{\"event_type\":\"message.received\"}'\n+        malformed_secret = \"[redacted:secret-assignment]\"\n+        msg_id = \"msg_123\"\n+        timestamp = str(int(time.time()))\n+        raw_sig = _svix_signature(\n+            body, malformed_secret.removeprefix(\"whsec_\"), msg_id, timestamp\n+        )\n+        req = _mock_request(\n+            headers={\n+                \"svix-id\": msg_id,\n+                \"svix-timestamp\": timestamp,\n+                \"svix-signature\": raw_sig,\n+            }\n+        )\n+        assert adapter._validate_signature(req, body, malformed_secret) is False\n+\n+    def test_validate_svix_signature_raw_secret_valid(self):\n+        \"\"\"Raw shared secrets are accepted for Svix-style senders without whsec_ secrets.\"\"\"\n+        adapter = _make_adapter()\n+        body = b'{\"event_type\":\"message.received\"}'\n+        secret = \"[redacted:secret-assignment]\"\n+        msg_id = \"msg_123\"\n+        timestamp = str(int(time.time()))\n+        sig = _svix_signature(body, secret, msg_id, timestamp)\n+        req = _mock_request(\n+            headers={\n+                \"svix-id\": msg_id,\n+                \"svix-timestamp\": timestamp,\n+                \"svix-signature\": sig,\n+            }\n+        )\n+        assert adapter._validate_signature(req, body, secret) is True\n+\n \n # ===================================================================\n # Prompt rendering","@@ -304,6 +445,27 @@ async def test_event_filter_empty_allows_all(self):\n             )\n             assert resp.status == 202\n \n+    @pytest.mark.asyncio\n+    async def test_event_filter_accepts_payload_type_field(self):\n+        \"\"\"Svix-style payloads often use a top-level `type` event field.\"\"\"\n+        routes = {\n+            \"svix\": {\n+                \"secret\": _INSECURE_NO_AUTH,\n+                \"events\": [\"message.received\"],\n+                \"prompt\": \"got it\",\n+            }\n+        }\n+        adapter = _make_adapter(routes=routes)\n+        adapter.handle_message = AsyncMock()\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.post(\n+                \"/webhooks/svix\",\n+                json={\"type\": \"message.received\"},\n+            )\n+            assert resp.status == 202\n+\n \n # ===================================================================\n # HTTP handling","@@ -432,6 +594,25 @@ async def test_expired_delivery_id_allows_reprocess(self):\n             resp2 = await cli.post(\"/webhooks/idem\", json={\"x\": 1}, headers=headers)\n             assert resp2.status == 202  # re-accepted\n \n+    @pytest.mark.asyncio\n+    async def test_svix_id_used_as_delivery_id_for_deduplication(self):\n+        \"\"\"Svix retries reuse svix-id, so use it as the delivery ID when present.\"\"\"\n+        routes = {\"idem\": {\"secret\": _INSECURE_NO_AUTH, \"prompt\": \"test\"}}\n+        adapter = _make_adapter(routes=routes)\n+        adapter.handle_message = AsyncMock()\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            headers = {\"svix-id\": \"msg_duplicate\"}\n+            resp1 = await cli.post(\"/webhooks/idem\", json={\"a\": 1}, headers=headers)\n+            assert resp1.status == 202\n+\n+            resp2 = await cli.post(\"/webhooks/idem\", json={\"a\": 1}, headers=headers)\n+            assert resp2.status == 200\n+            data = await resp2.json()\n+            assert data[\"status\"] == \"duplicate\"\n+            assert data[\"delivery_id\"] == \"msg_duplicate\"\n+\n \n # ===================================================================\n # Rate limiting\n"]}]},{"kind":"pr","number":43595,"title":"fix(webhook): support Fireflies V2 signature header and event field","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":43705,"title":"fix(webhook): add Fireflies V2 signature and event support (#43575)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43595","number":43595},{"kind":"pr","value":"#43575","number":43575}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":47849,"title":"feat(webhook): accept Standard Webhooks signatures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e40192118ee","number":0},{"kind":"pr","value":"#104183","number":104183},{"kind":"pr","value":"#104154","number":104154}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":68791,"title":"Feat(webhook): route-configurable signature and event type headers","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"6c40c3784d1c","number":0},{"kind":"pr","value":"#68768","number":68768},{"kind":"pr","value":"#68791","number":68791},{"kind":"pr","value":"#68791","number":68791},{"kind":"pr","value":"#68791","number":68791},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#68768","number":68768},{"kind":"pr","value":"#68791","number":68791}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":92024,"title":"feat(webhook): Standard Webhooks senders now authenticate (fixes #47451, salvage #47849)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e40192118ee","number":0},{"kind":"pr","value":"#104183","number":104183},{"kind":"pr","value":"#47849","number":47849},{"kind":"pr","value":"#104154","number":104154}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":103167,"title":"fix(webhook): accept Standard Webhooks (webhook-*) signature headers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e40192118ee","number":0},{"kind":"pr","value":"#104183","number":104183},{"kind":"pr","value":"#104154","number":104154}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":104183,"title":"feat(webhook): Standard Webhooks senders authenticate; legacy GitLab token survives (#47451, #101837, salvage #47849)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["evals/webhook_auth/standard_webhooks_ab.py","gateway/platforms/webhook.py","tests/gateway/test_webhook_adapter.py","website/docs/user-guide/messaging/webhooks.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}