diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index afd392215..b74ee8c27 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -2898,7 +2898,12 @@ async def _handle_webhook_delivery(self, task_dict: dict[str, Any]) -> None: "timeout": http_config.timeout_seconds, } if http_config.method.upper() == "GET": - response = await self._http_client.get(url, **request_kwargs) + # GET webhooks use the same signed-payload contract as POST webhooks, + # so the exact bytes covered by X-Hindsight-Signature must be sent. GET + # caches do not generally key on body content, so prevent a cached + # response from suppressing delivery of a later event to the same URL. + headers["Cache-Control"] = "no-cache, no-store" + response = await self._http_client.request("GET", url, content=payload_bytes, **request_kwargs) else: response = await self._http_client.post(url, content=payload_bytes, **request_kwargs) response.raise_for_status() diff --git a/hindsight-api-slim/tests/test_webhooks.py b/hindsight-api-slim/tests/test_webhooks.py index 04b159c1e..31ff0a232 100644 --- a/hindsight-api-slim/tests/test_webhooks.py +++ b/hindsight-api-slim/tests/test_webhooks.py @@ -7,6 +7,8 @@ - HTTP API integration tests for CRUD and delivery listing endpoints """ +import hashlib +import hmac import json import uuid from datetime import datetime, timezone @@ -407,6 +409,43 @@ async def test_deliver_success(self, memory: MemoryEngine): # Should not raise await memory._handle_webhook_delivery(task_dict) + @pytest.mark.asyncio + async def test_get_delivers_the_signed_payload(self, memory: MemoryEngine): + """A GET delivery sends the exact payload bytes covered by its signature.""" + task_dict = _make_delivery_task(retry_count=0) + task_dict["secret"] = "test-secret" + task_dict["http_config"] = {"method": "GET", "params": {"source": "hindsight"}} + received_request: httpx.Request | None = None + + async def receive(request: httpx.Request) -> httpx.Response: + nonlocal received_request + received_request = request + return httpx.Response(204) + + transport = httpx.MockTransport(receive) + async with httpx.AsyncClient(transport=transport) as client: + with patch.object(memory, "_http_client", client): + await memory._handle_webhook_delivery(task_dict) + + assert received_request is not None + expected_signature = "sha256=" + hmac.new(b"test-secret", received_request.content, hashlib.sha256).hexdigest() + + assert received_request.method == "GET" + assert received_request.url == "https://example.com/hook?source=hindsight" + assert received_request.content == task_dict["payload"].encode() + assert received_request.headers["X-Hindsight-Signature"] == expected_signature + assert received_request.headers["Cache-Control"] == "no-cache, no-store" + + @pytest.mark.asyncio + async def test_get_delivery_failure_raises_retry_task_at(self, memory: MemoryEngine): + """A failed HTTP GET schedules a retry just like a failed POST.""" + task_dict = _make_delivery_task(retry_count=0) + task_dict["http_config"] = {"method": "GET"} + + with patch.object(memory._http_client, "request", new=AsyncMock(side_effect=Exception("connection refused"))): + with pytest.raises(RetryTaskAt): + await memory._handle_webhook_delivery(task_dict) + @pytest.mark.asyncio async def test_deliver_failure_raises_retry_task_at(self, memory: MemoryEngine): """A failed HTTP POST raises RetryTaskAt when retries remain.""" diff --git a/hindsight-docs/docs/developer/api/operations.mdx b/hindsight-docs/docs/developer/api/operations.mdx index 7346d7abd..c5258b1ab 100644 --- a/hindsight-docs/docs/developer/api/operations.mdx +++ b/hindsight-docs/docs/developer/api/operations.mdx @@ -96,7 +96,7 @@ Bank-deduped at submit time, so concurrent triggers against the same bank coales ### `webhook_delivery` -After certain operations complete (e.g., consolidation finishing on a bank with a registered webhook), Hindsight enqueues a `webhook_delivery` task. The handler POSTs the payload to the configured URL and retries on transient failures. +After certain operations complete (e.g., consolidation finishing on a bank with a registered webhook), Hindsight enqueues a `webhook_delivery` task. The handler sends the payload to the configured URL (POST by default, or GET when configured) and retries on transient failures. ## Endpoints diff --git a/hindsight-docs/docs/developer/api/webhooks.mdx b/hindsight-docs/docs/developer/api/webhooks.mdx index 5d7c1cc73..eba807191 100644 --- a/hindsight-docs/docs/developer/api/webhooks.mdx +++ b/hindsight-docs/docs/developer/api/webhooks.mdx @@ -4,7 +4,19 @@ sidebar_position: 10 # Webhooks -Hindsight can notify your application in real-time when memory events occur by sending HTTP POST requests to a URL you configure. +Hindsight can notify your application in real-time when memory events occur by sending HTTP requests to a URL you configure. Deliveries use POST by default; per-bank webhooks can select GET through `http_config.method`. + +## HTTP Delivery + +The event payload is sent as the JSON request body for both POST and GET deliveries. Because GET request bodies are not supported consistently by every HTTP server, proxy, or framework, use POST unless the receiving stack is known to preserve GET bodies. GET deliveries include `Cache-Control: no-cache, no-store` so caches do not suppress later events sent to the same URL. + +Every delivery includes `X-Hindsight-Event` with the event type. When the webhook has a signing secret, it also includes `X-Hindsight-Signature` in the following form: + +```text +sha256= +``` + +Verify the signature against the exact request body bytes before parsing JSON. Re-serializing parsed JSON can change whitespace or escaping and produce a different signature. ## Delivery and Retries @@ -136,4 +148,3 @@ Fired when a bank's [Memory Defense](../memory-defense/index.md) policy acts on **Notes:** - A `redact` event means the secret was scrubbed and the redacted memory was still stored. A `block` event means the item was dropped; if every item in the retain request is blocked, the retain call returns `422`. - diff --git a/skills/hindsight-docs/references/developer/api/operations.md b/skills/hindsight-docs/references/developer/api/operations.md index 6746535a7..89deb869e 100644 --- a/skills/hindsight-docs/references/developer/api/operations.md +++ b/skills/hindsight-docs/references/developer/api/operations.md @@ -84,7 +84,7 @@ Bank-deduped at submit time, so concurrent triggers against the same bank coales ### `webhook_delivery` -After certain operations complete (e.g., consolidation finishing on a bank with a registered webhook), Hindsight enqueues a `webhook_delivery` task. The handler POSTs the payload to the configured URL and retries on transient failures. +After certain operations complete (e.g., consolidation finishing on a bank with a registered webhook), Hindsight enqueues a `webhook_delivery` task. The handler sends the payload to the configured URL (POST by default, or GET when configured) and retries on transient failures. ## Endpoints diff --git a/skills/hindsight-docs/references/developer/api/webhooks.md b/skills/hindsight-docs/references/developer/api/webhooks.md index 36a1b4f17..4f215717d 100644 --- a/skills/hindsight-docs/references/developer/api/webhooks.md +++ b/skills/hindsight-docs/references/developer/api/webhooks.md @@ -1,7 +1,19 @@ # Webhooks -Hindsight can notify your application in real-time when memory events occur by sending HTTP POST requests to a URL you configure. +Hindsight can notify your application in real-time when memory events occur by sending HTTP requests to a URL you configure. Deliveries use POST by default; per-bank webhooks can select GET through `http_config.method`. + +## HTTP Delivery + +The event payload is sent as the JSON request body for both POST and GET deliveries. Because GET request bodies are not supported consistently by every HTTP server, proxy, or framework, use POST unless the receiving stack is known to preserve GET bodies. GET deliveries include `Cache-Control: no-cache, no-store` so caches do not suppress later events sent to the same URL. + +Every delivery includes `X-Hindsight-Event` with the event type. When the webhook has a signing secret, it also includes `X-Hindsight-Signature` in the following form: + +```text +sha256= +``` + +Verify the signature against the exact request body bytes before parsing JSON. Re-serializing parsed JSON can change whitespace or escaping and produce a different signature. ## Delivery and Retries