diff --git a/CHANGELOG.md b/CHANGELOG.md
index b056784..0329ab2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ existing `0.1.0` release; earlier development prereleases are not listed.
## [Unreleased]
+### Added
+
+- Add Forward Webhook endpoint creation, listing, retrieval, partial update, deletion, enable/disable and synthetic test delivery.
+- Add raw-body webhook signature verification with HMAC-SHA256, multiple v1 signatures and a 300-second timestamp window; include receiver examples and offline contracts.
+
+
## [0.8.0]
### Fixed
diff --git a/README.md b/README.md
index aa50764..0275478 100644
--- a/README.md
+++ b/README.md
@@ -764,3 +764,7 @@ Runnable scenarios live in `examples/`, organized by mode, with one file per sce
## License
Released under the [MIT License](LICENSE).
+
+## Forward Webhooks (next release)
+
+Manage webhook endpoints and verify signed event deliveries. See [Webhook usage and receiver examples](docs/webhooks.md).
diff --git a/docs/api/reference.md b/docs/api/reference.md
index 0b6c81e..48e2d3a 100644
--- a/docs/api/reference.md
+++ b/docs/api/reference.md
@@ -2066,6 +2066,17 @@ class Forward(SyncAPIClient)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
+
+#### webhooks
+
+```python
+@cached_property
+def webhooks() -> Webhooks
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
#### drives
@@ -2274,6 +2285,17 @@ class AsyncForward(AsyncAPIClient)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
+
+#### webhooks
+
+```python
+@cached_property
+def webhooks() -> AsyncWebhooks
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
#### drives
@@ -9817,6 +9839,394 @@ DELETE /vaults/{vault_id}.
identity_id selects identity ownership with a PAT; omit it with a SAT.
+
+
+# qca.forward.resources.webhooks
+
+
+
+## Webhooks
+
+```python
+class Webhooks(SyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+
+
+#### endpoints
+
+```python
+@cached_property
+def endpoints() -> WebhookEndpoints
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+
+
+## WebhookEndpoints
+
+```python
+class WebhookEndpoints(SyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+
+
+#### create
+
+```python
+def create(
+ *,
+ url: str,
+ events: List[str],
+ description: Union[str, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, str], NotGiven] = NOT_GIVEN,
+ idempotency_key: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpointCreateResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+POST /webhook/endpoints. Requires a PAT or administrator SAT.
+
+
+
+#### list
+
+```python
+def list(
+ *,
+ limit: Union[int, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> SyncPage[WebhookEndpoint]
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+GET /webhook/endpoints. Requires a PAT or administrator SAT.
+
+
+
+#### retrieve
+
+```python
+def retrieve(
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpoint
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+GET /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.
+
+
+
+#### update
+
+```python
+def update(
+ endpoint_id: str,
+ *,
+ url: Union[str, NotGiven] = NOT_GIVEN,
+ events: Union[List[str], NotGiven] = NOT_GIVEN,
+ description: Union[str, NotGiven] = NOT_GIVEN,
+ active: Union[bool, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, str | None], NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpoint
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+PUT /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.
+
+
+
+#### delete
+
+```python
+def delete(
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN) -> None
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+DELETE /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.
+
+
+
+#### enable
+
+```python
+def enable(
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpoint
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+POST /webhook/endpoints/{endpoint_id}/enable. Requires a PAT or administrator SAT.
+
+
+
+#### disable
+
+```python
+def disable(
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpoint
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+POST /webhook/endpoints/{endpoint_id}/disable. Requires a PAT or administrator SAT.
+
+
+
+#### test
+
+```python
+def test(
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpointTestResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+POST /webhook/endpoints/{endpoint_id}/test. Queues a test event; 202 does not confirm delivery.
+
+
+
+## AsyncWebhooks
+
+```python
+class AsyncWebhooks(AsyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+
+
+#### endpoints
+
+```python
+@cached_property
+def endpoints() -> AsyncWebhookEndpoints
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+
+
+## AsyncWebhookEndpoints
+
+```python
+class AsyncWebhookEndpoints(AsyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+
+
+#### create
+
+```python
+async def create(
+ *,
+ url: str,
+ events: List[str],
+ description: Union[str, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, str], NotGiven] = NOT_GIVEN,
+ idempotency_key: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpointCreateResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+POST /webhook/endpoints. Requires a PAT or administrator SAT.
+
+
+
+#### list
+
+```python
+def list(
+ *,
+ limit: Union[int, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> AsyncPaginator[WebhookEndpoint]
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+GET /webhook/endpoints. Requires a PAT or administrator SAT.
+
+
+
+#### retrieve
+
+```python
+async def retrieve(
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpoint
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+GET /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.
+
+
+
+#### update
+
+```python
+async def update(
+ endpoint_id: str,
+ *,
+ url: Union[str, NotGiven] = NOT_GIVEN,
+ events: Union[List[str], NotGiven] = NOT_GIVEN,
+ description: Union[str, NotGiven] = NOT_GIVEN,
+ active: Union[bool, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, str | None], NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpoint
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+PUT /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.
+
+
+
+#### delete
+
+```python
+async def delete(
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN) -> None
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+DELETE /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.
+
+
+
+#### enable
+
+```python
+async def enable(
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpoint
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+POST /webhook/endpoints/{endpoint_id}/enable. Requires a PAT or administrator SAT.
+
+
+
+#### disable
+
+```python
+async def disable(
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpoint
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+POST /webhook/endpoints/{endpoint_id}/disable. Requires a PAT or administrator SAT.
+
+
+
+#### test
+
+```python
+async def test(
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> WebhookEndpointTestResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py)
+
+POST /webhook/endpoints/{endpoint_id}/test. Queues a test event; 202 does not confirm delivery.
+
# qca.forward.types.batch
@@ -17513,6 +17923,255 @@ Select identity ownership with a PAT. Omit when using a SAT.
#### name
+
+
+# qca.forward.types.webhook\_endpoint
+
+
+
+## WebhookEndpointCreateResponse
+
+```python
+class WebhookEndpointCreateResponse(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint.py)
+
+The signing secret is returned only when creating an endpoint.
+
+
+
+#### id
+
+
+
+#### url
+
+
+
+#### events
+
+
+
+#### description
+
+
+
+#### metadata
+
+
+
+#### active
+
+
+
+#### created\_at
+
+
+
+#### signing\_secret
+
+
+
+## WebhookEndpoint
+
+```python
+class WebhookEndpoint(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint.py)
+
+
+
+#### id
+
+
+
+#### url
+
+
+
+#### events
+
+
+
+#### description
+
+
+
+#### metadata
+
+
+
+#### active
+
+
+
+#### created\_at
+
+
+
+#### updated\_at
+
+
+
+#### last\_success\_at
+
+
+
+#### last\_failure\_at
+
+
+
+#### disabled\_reason
+
+
+
+#### consecutive\_fail
+
+
+
+## WebhookEndpointTestResponse
+
+```python
+class WebhookEndpointTestResponse(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint.py)
+
+Accepted delivery rows, not a delivery success report.
+
+
+
+#### event\_id
+
+
+
+#### delivery\_rows
+
+
+
+# qca.forward.types.webhook\_endpoint\_create\_params
+
+
+
+## WebhookEndpointCreateParams
+
+```python
+class WebhookEndpointCreateParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint_create_params.py)
+
+
+
+#### url
+
+
+
+#### events
+
+
+
+#### description
+
+
+
+#### metadata
+
+
+
+#### idempotency\_key
+
+
+
+# qca.forward.types.webhook\_endpoint\_list\_params
+
+
+
+## WebhookEndpointListParams
+
+```python
+class WebhookEndpointListParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint_list_params.py)
+
+
+
+#### limit
+
+
+
+#### after\_id
+
+
+
+# qca.forward.types.webhook\_endpoint\_update\_params
+
+
+
+## WebhookEndpointUpdateParams
+
+```python
+class WebhookEndpointUpdateParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint_update_params.py)
+
+
+
+#### url
+
+
+
+#### events
+
+
+
+#### description
+
+
+
+#### active
+
+
+
+#### metadata
+
+
+
+# qca.forward.webhook\_signature
+
+Verify Forward delivery signatures before parsing the original request body.
+
+
+
+## WebhookVerificationError
+
+```python
+class WebhookVerificationError(ValueError)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/webhook_signature.py)
+
+Missing, invalid, expired or mismatched delivery authentication.
+
+
+
+#### verify\_webhook\_signature
+
+```python
+def verify_webhook_signature(payload: bytes, headers: Mapping[str, str],
+ secret: str) -> None
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/webhook_signature.py)
+
+Accept any matching v1 signature within 300 seconds; raise on failure.
+
+Use original body bytes. After verification, deduplicate using Webhook-ID.
+
# qca.managed
diff --git a/docs/webhooks.md b/docs/webhooks.md
new file mode 100644
index 0000000..6714656
--- /dev/null
+++ b/docs/webhooks.md
@@ -0,0 +1,116 @@
+# Forward Webhooks
+
+Available in the next SDK release. Configure account notifications with a PAT or
+administrator SAT. Identity SATs cannot manage webhook endpoints. Use the same
+regional Forward client and shared authentication configuration as other APIs.
+
+## Endpoint operations
+
+All paths are relative to `/api/v1/forward`.
+
+| Method | Path | Result |
+| --- | --- | --- |
+| POST | `/webhook/endpoints` | Create; HTTP 201, includes `signing_secret` once |
+| GET | `/webhook/endpoints` | List; `limit` and `after_id`, preserving server order |
+| GET | `/webhook/endpoints/{endpoint_id}` | Retrieve |
+| PUT | `/webhook/endpoints/{endpoint_id}` | Partial update |
+| DELETE | `/webhook/endpoints/{endpoint_id}` | Delete; HTTP 204, no response body |
+| POST | `/webhook/endpoints/{endpoint_id}/enable` | Enable |
+| POST | `/webhook/endpoints/{endpoint_id}/disable` | Disable |
+| POST | `/webhook/endpoints/{endpoint_id}/test` | HTTP 202, `event_id` and `delivery_rows` |
+
+- `events` accepts full event names or `*`. Strings remain open for future event
+ types; the SDK does not maintain a closed event enumeration. The current official
+ catalog includes `forward.schedule.archived`.
+- Omitted update fields are unchanged. `active: false` disables an endpoint.
+ `events` replaces the subscription list. `metadata` merges keys; a null entry
+ deletes that key. Create metadata values are strings.
+- Securely store the create response's `signing_secret` before leaving the creation
+ flow. List/retrieve/update cannot recover it. Do not log the create response.
+- `test` only queues a synthetic event; 202 and a positive `delivery_rows` do not
+ confirm receipt. A disabled endpoint may return zero rows. Verify actual delivery
+ at your receiver. The SDK does not poll delivery state or automatically replay
+ a test POST without an idempotency key.
+
+## Manage subscriptions
+
+The following uses an already configured `client` and a callback URL that you
+control. Only call `test` when that receiver is ready. Use a unique idempotency key
+for creation, and reuse it only for the same request.
+
+```python
+created = client.webhooks.endpoints.create(
+ url=callback_url,
+ events=["forward.schedule.archived"],
+ metadata={"team": "sdk"},
+ idempotency_key=create_key,
+)
+# Persist created.signing_secret securely; never print the response.
+endpoint = client.webhooks.endpoints.retrieve(created.id)
+client.webhooks.endpoints.update(created.id, active=False, metadata={"old_key": None})
+client.webhooks.endpoints.enable(created.id)
+accepted = client.webhooks.endpoints.test(created.id)
+for endpoint in client.webhooks.endpoints.list(limit=20):
+ print(endpoint.id)
+```
+
+`AsyncForward` exposes the same methods: await individual requests and use
+`async for` on `client.webhooks.endpoints.list(...)`. Raw response wrappers remain
+available for status, headers and request IDs.
+
+## Verify deliveries
+
+See [the runnable example](../examples/forward/webhooks.py):
+
+```python
+from qca.forward import verify_webhook_signature
+import json
+
+verify_webhook_signature(raw_body, request_headers, signing_secret)
+event = json.loads(raw_body)
+```
+
+The synchronous verifier also works in async HTTP handlers.
+
+Pass the **original HTTP request body bytes**, all request headers, and the stored
+`whsec_...` secret. Verification is local and makes no API call. It checks
+`Webhook-ID`, `Webhook-Timestamp` and `Webhook-Signature`, decodes the secret with
+standard Base64, and verifies HMAC-SHA256 over `id.timestamp.raw_body`. It accepts
+any matching `v1` signature in the whitespace-separated header, checks a fixed
+300-second past/future window, and rejects malformed or mismatched input with a
+webhook verification error. Keep the receiver clock synchronized.
+
+Parse JSON only after verification; do not reserialize it before checking the
+signature. Preserve unknown event types and fields. Ordinary events contain
+`data.type`; the synthetic test event instead has `endpoint_id`, `emitted_at` and
+`note`, with no required `data`. The `Webhook-Event-Type` header is not part of the
+signed message; use the verified payload for business decisions.
+
+In a real HTTP handler, reject verification failures, bound the body size, store
+accepted events durably, and deduplicate using `Webhook-ID` before acknowledging
+with 2xx. Retries share the same ID. The SDK does not supply a queue or a deduplication
+database. The examples only parse an authenticated delivery and list endpoints;
+they do not start a production receiver or create subscriptions automatically.
+
+## Clean up a test endpoint
+
+Run this step separately for a temporary test endpoint, after your receiver has
+confirmed receipt and successfully verified the expected test delivery. Keep the
+endpoint active while waiting: disabling or deleting it can discard queued
+deliveries. HTTP 202 and `delivery_rows` confirm queuing, not receipt.
+
+```python
+client.webhooks.endpoints.disable(created.id)
+client.webhooks.endpoints.delete(created.id)
+```
+
+## Validation boundary
+
+Offline tests cover all eight HTTP contracts, errors, cursor paging, omitted/null/
+false updates, empty bodies, signature vectors, tampering, expired/future timestamps,
+multiple signature candidates and unknown fields. Live CN/Global callback delivery
+requires a reachable receiver controlled by the application team and is not implied
+by these tests.
+
+References: [API overview](https://docs.qoder.com/zh/cloud-agents/api/webhooks/overview),
+[receiving events](https://docs.qoder.com/zh/cloud-agents/api/webhooks/receive).
diff --git a/examples/forward/webhooks.py b/examples/forward/webhooks.py
new file mode 100644
index 0000000..b86bdbc
--- /dev/null
+++ b/examples/forward/webhooks.py
@@ -0,0 +1,29 @@
+"""List webhook endpoints; parse_delivery can be used inside your HTTP receiver.
+
+Run: python -m examples.forward.webhooks
+The management client uses the shared PAT/SA configuration (administrator SAT required).
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping
+from typing import Any
+
+from examples.common.live import Run, run_cli
+from qca import Forward
+from qca.forward import verify_webhook_signature
+
+
+def parse_delivery(raw_body: bytes, headers: Mapping[str, str], secret: str) -> Any:
+ verify_webhook_signature(raw_body, headers, secret)
+ return json.loads(raw_body)
+
+
+def run(client: Forward, context: Run) -> None:
+ for endpoint in client.webhooks.endpoints.list(limit=20):
+ context.output("webhook_endpoint", {"id": endpoint.id, "active": endpoint.active})
+
+
+if __name__ == "__main__":
+ run_cli("forward", Forward, {"webhooks": run})
diff --git a/src/qca/forward/__init__.py b/src/qca/forward/__init__.py
index 0ff954d..49bbc87 100644
--- a/src/qca/forward/__init__.py
+++ b/src/qca/forward/__init__.py
@@ -21,6 +21,8 @@
from .resources.realtime import (
RealtimeTimeoutError as RealtimeTimeoutError,
)
+from .webhook_signature import WebhookVerificationError as WebhookVerificationError
+from .webhook_signature import verify_webhook_signature as verify_webhook_signature
Client = Forward
AsyncClient = AsyncForward
diff --git a/src/qca/forward/_client.py b/src/qca/forward/_client.py
index 437fe84..f2259b7 100644
--- a/src/qca/forward/_client.py
+++ b/src/qca/forward/_client.py
@@ -22,9 +22,14 @@
from .resources.templates import AsyncTemplates, Templates
from .resources.usage import AsyncUsage, Usage
from .resources.vaults.vaults import AsyncVaults, Vaults
+from .resources.webhooks import AsyncWebhooks, Webhooks
class Forward(SyncAPIClient):
+ @cached_property
+ def webhooks(self) -> Webhooks:
+ return Webhooks(self)
+
@cached_property
def drives(self) -> Drives:
return Drives(self)
@@ -102,6 +107,10 @@ def models(self) -> Models:
class AsyncForward(AsyncAPIClient):
+ @cached_property
+ def webhooks(self) -> AsyncWebhooks:
+ return AsyncWebhooks(self)
+
@cached_property
def drives(self) -> AsyncDrives:
return AsyncDrives(self)
diff --git a/src/qca/forward/resources/webhooks.py b/src/qca/forward/resources/webhooks.py
new file mode 100644
index 0000000..edfabdb
--- /dev/null
+++ b/src/qca/forward/resources/webhooks.py
@@ -0,0 +1,422 @@
+from __future__ import annotations
+
+from functools import cached_property
+from typing import Any, Dict, List, Union
+
+import httpx
+
+from qca.common._resource import AsyncAPIResource, SyncAPIResource
+from qca.common._types import NOT_GIVEN, NotGiven
+from qca.common._utils import make_request_options, path_template
+from qca.common.pagination import AsyncPaginator, SyncPage
+from qca.forward.types.webhook_endpoint import (
+ WebhookEndpoint,
+ WebhookEndpointCreateResponse,
+ WebhookEndpointTestResponse,
+)
+
+
+class Webhooks(SyncAPIResource):
+ @cached_property
+ def endpoints(self) -> WebhookEndpoints:
+ return WebhookEndpoints(self._client)
+
+
+class WebhookEndpoints(SyncAPIResource):
+ def create(
+ self,
+ *,
+ url: str,
+ events: List[str],
+ description: Union[str, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, str], NotGiven] = NOT_GIVEN,
+ idempotency_key: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpointCreateResponse:
+ """POST /webhook/endpoints. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints")
+ options = make_request_options(
+ body={"url": url, "events": events, "description": description, "metadata": metadata},
+ query={},
+ headers={"Idempotency-Key": idempotency_key},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("POST", _path, cast_to=WebhookEndpointCreateResponse, options=options)
+
+ def list(
+ self,
+ *,
+ limit: Union[int, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> SyncPage[WebhookEndpoint]:
+ """GET /webhook/endpoints. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints")
+ options = make_request_options(
+ body={},
+ query={"limit": limit, "after_id": after_id},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("GET", _path, cast_to=WebhookEndpoint, options=options, page_style="Page")
+
+ def retrieve(
+ self,
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpoint:
+ """GET /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("GET", _path, cast_to=WebhookEndpoint, options=options)
+
+ def update(
+ self,
+ endpoint_id: str,
+ *,
+ url: Union[str, NotGiven] = NOT_GIVEN,
+ events: Union[List[str], NotGiven] = NOT_GIVEN,
+ description: Union[str, NotGiven] = NOT_GIVEN,
+ active: Union[bool, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, str | None], NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpoint:
+ """PUT /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={"url": url, "events": events, "description": description, "active": active, "metadata": metadata},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("PUT", _path, cast_to=WebhookEndpoint, options=options)
+
+ def delete(
+ self,
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> None:
+ """DELETE /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ if extra_body is None:
+ options["body"] = None
+ return self._client.request("DELETE", _path, cast_to=None, options=options)
+
+ def enable(
+ self,
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpoint:
+ """POST /webhook/endpoints/{endpoint_id}/enable. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}/enable", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ if extra_body is None:
+ options["body"] = None
+ return self._client.request("POST", _path, cast_to=WebhookEndpoint, options=options)
+
+ def disable(
+ self,
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpoint:
+ """POST /webhook/endpoints/{endpoint_id}/disable. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}/disable", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ if extra_body is None:
+ options["body"] = None
+ return self._client.request("POST", _path, cast_to=WebhookEndpoint, options=options)
+
+ def test(
+ self,
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpointTestResponse:
+ """POST /webhook/endpoints/{endpoint_id}/test. Queues a test event; 202 does not confirm delivery."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}/test", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ if extra_body is None:
+ options["body"] = None
+ return self._client.request("POST", _path, cast_to=WebhookEndpointTestResponse, options=options)
+
+
+class AsyncWebhooks(AsyncAPIResource):
+ @cached_property
+ def endpoints(self) -> AsyncWebhookEndpoints:
+ return AsyncWebhookEndpoints(self._client)
+
+
+class AsyncWebhookEndpoints(AsyncAPIResource):
+ async def create(
+ self,
+ *,
+ url: str,
+ events: List[str],
+ description: Union[str, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, str], NotGiven] = NOT_GIVEN,
+ idempotency_key: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpointCreateResponse:
+ """POST /webhook/endpoints. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints")
+ options = make_request_options(
+ body={"url": url, "events": events, "description": description, "metadata": metadata},
+ query={},
+ headers={"Idempotency-Key": idempotency_key},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return await self._client.request("POST", _path, cast_to=WebhookEndpointCreateResponse, options=options)
+
+ def list(
+ self,
+ *,
+ limit: Union[int, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> AsyncPaginator[WebhookEndpoint]:
+ """GET /webhook/endpoints. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints")
+ options = make_request_options(
+ body={},
+ query={"limit": limit, "after_id": after_id},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return AsyncPaginator(
+ lambda: self._client.request("GET", _path, cast_to=WebhookEndpoint, options=options, page_style="Page")
+ )
+
+ async def retrieve(
+ self,
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpoint:
+ """GET /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return await self._client.request("GET", _path, cast_to=WebhookEndpoint, options=options)
+
+ async def update(
+ self,
+ endpoint_id: str,
+ *,
+ url: Union[str, NotGiven] = NOT_GIVEN,
+ events: Union[List[str], NotGiven] = NOT_GIVEN,
+ description: Union[str, NotGiven] = NOT_GIVEN,
+ active: Union[bool, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, str | None], NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpoint:
+ """PUT /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={"url": url, "events": events, "description": description, "active": active, "metadata": metadata},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return await self._client.request("PUT", _path, cast_to=WebhookEndpoint, options=options)
+
+ async def delete(
+ self,
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> None:
+ """DELETE /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ if extra_body is None:
+ options["body"] = None
+ return await self._client.request("DELETE", _path, cast_to=None, options=options)
+
+ async def enable(
+ self,
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpoint:
+ """POST /webhook/endpoints/{endpoint_id}/enable. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}/enable", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ if extra_body is None:
+ options["body"] = None
+ return await self._client.request("POST", _path, cast_to=WebhookEndpoint, options=options)
+
+ async def disable(
+ self,
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpoint:
+ """POST /webhook/endpoints/{endpoint_id}/disable. Requires a PAT or administrator SAT."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}/disable", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ if extra_body is None:
+ options["body"] = None
+ return await self._client.request("POST", _path, cast_to=WebhookEndpoint, options=options)
+
+ async def test(
+ self,
+ endpoint_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> WebhookEndpointTestResponse:
+ """POST /webhook/endpoints/{endpoint_id}/test. Queues a test event; 202 does not confirm delivery."""
+ _path = path_template("/webhook/endpoints/{endpoint_id}/test", endpoint_id=endpoint_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ if extra_body is None:
+ options["body"] = None
+ return await self._client.request("POST", _path, cast_to=WebhookEndpointTestResponse, options=options)
diff --git a/src/qca/forward/types/__init__.py b/src/qca/forward/types/__init__.py
index 581bad2..934732e 100644
--- a/src/qca/forward/types/__init__.py
+++ b/src/qca/forward/types/__init__.py
@@ -247,6 +247,12 @@
from .vault_credential_update_params import VaultCredentialUpdateAuth as VaultCredentialUpdateAuth
from .vault_credential_update_params import VaultCredentialUpdateParams as VaultCredentialUpdateParams
from .vault_list_params import VaultListParams as VaultListParams
+from .webhook_endpoint import WebhookEndpoint as WebhookEndpoint
+from .webhook_endpoint import WebhookEndpointCreateResponse as WebhookEndpointCreateResponse
+from .webhook_endpoint import WebhookEndpointTestResponse as WebhookEndpointTestResponse
+from .webhook_endpoint_create_params import WebhookEndpointCreateParams as WebhookEndpointCreateParams
+from .webhook_endpoint_list_params import WebhookEndpointListParams as WebhookEndpointListParams
+from .webhook_endpoint_update_params import WebhookEndpointUpdateParams as WebhookEndpointUpdateParams
_namespace = dict(
globals(),
diff --git a/src/qca/forward/types/webhook_endpoint.py b/src/qca/forward/types/webhook_endpoint.py
new file mode 100644
index 0000000..9ba2655
--- /dev/null
+++ b/src/qca/forward/types/webhook_endpoint.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from pydantic import Field
+
+from qca.common._models import BaseModel
+
+
+class WebhookEndpointCreateResponse(BaseModel):
+ """The signing secret is returned only when creating an endpoint."""
+
+ id: str | None = None
+ url: str | None = None
+ events: list[str] | None = None
+ description: str | None = None
+ metadata: dict[str, Any] | None = None
+ active: bool | None = None
+ created_at: datetime | None = None
+ signing_secret: str | None = Field(default=None, repr=False)
+
+
+class WebhookEndpoint(BaseModel):
+ id: str | None = None
+ url: str | None = None
+ events: list[str] | None = None
+ description: str | None = None
+ metadata: dict[str, Any] | None = None
+ active: bool | None = None
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+ last_success_at: datetime | None = None
+ last_failure_at: datetime | None = None
+ disabled_reason: str | None = None
+ consecutive_fail: int | None = None
+
+
+class WebhookEndpointTestResponse(BaseModel):
+ """Accepted delivery rows, not a delivery success report."""
+
+ event_id: int | None = None
+ delivery_rows: int | None = None
diff --git a/src/qca/forward/types/webhook_endpoint_create_params.py b/src/qca/forward/types/webhook_endpoint_create_params.py
new file mode 100644
index 0000000..a125ba9
--- /dev/null
+++ b/src/qca/forward/types/webhook_endpoint_create_params.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from typing_extensions import Required, TypedDict
+
+
+class WebhookEndpointCreateParams(TypedDict, total=False):
+ url: Required[str]
+ events: Required[list[str]]
+ description: str
+ metadata: dict[str, str]
+ idempotency_key: str
diff --git a/src/qca/forward/types/webhook_endpoint_list_params.py b/src/qca/forward/types/webhook_endpoint_list_params.py
new file mode 100644
index 0000000..a625cd8
--- /dev/null
+++ b/src/qca/forward/types/webhook_endpoint_list_params.py
@@ -0,0 +1,8 @@
+from __future__ import annotations
+
+from typing_extensions import TypedDict
+
+
+class WebhookEndpointListParams(TypedDict, total=False):
+ limit: int
+ after_id: str
diff --git a/src/qca/forward/types/webhook_endpoint_update_params.py b/src/qca/forward/types/webhook_endpoint_update_params.py
new file mode 100644
index 0000000..271075d
--- /dev/null
+++ b/src/qca/forward/types/webhook_endpoint_update_params.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from typing_extensions import TypedDict
+
+
+class WebhookEndpointUpdateParams(TypedDict, total=False):
+ url: str
+ events: list[str]
+ description: str
+ active: bool
+ metadata: dict[str, str | None]
diff --git a/src/qca/forward/webhook_signature.py b/src/qca/forward/webhook_signature.py
new file mode 100644
index 0000000..2cd6248
--- /dev/null
+++ b/src/qca/forward/webhook_signature.py
@@ -0,0 +1,63 @@
+"""Verify Forward delivery signatures before parsing the original request body."""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import hashlib
+import hmac
+import time
+from collections.abc import Mapping
+
+
+class WebhookVerificationError(ValueError):
+ """Missing, invalid, expired or mismatched delivery authentication."""
+
+
+def verify_webhook_signature(payload: bytes, headers: Mapping[str, str], secret: str) -> None:
+ """Accept any matching v1 signature within 300 seconds; raise on failure.
+
+ Use original body bytes. After verification, deduplicate using Webhook-ID.
+ """
+
+ def invalid() -> WebhookVerificationError:
+ return WebhookVerificationError("webhook signature verification failed")
+
+ def header(name: str) -> str:
+ values = [v for k, v in headers.items() if k.lower() == name]
+ return values[0] if len(values) == 1 else ""
+
+ if not isinstance(payload, bytes):
+ raise invalid()
+ event_id = header("webhook-id")
+ timestamp = header("webhook-timestamp")
+ if (
+ not event_id
+ or any(c in event_id for c in ".\r\n")
+ or not timestamp
+ or not timestamp.isascii()
+ or not timestamp.isdecimal()
+ or len(timestamp) > 19
+ or not secret.startswith("whsec_")
+ ):
+ raise invalid()
+ now = int(time.time())
+ if not now - 300 <= int(timestamp) <= now + 300:
+ raise invalid()
+ try:
+ key = base64.b64decode(secret[6:], validate=True)
+ except (ValueError, binascii.Error):
+ raise invalid() from None
+ if not 24 <= len(key) <= 64 or base64.b64encode(key).decode("ascii") != secret[6:]:
+ raise invalid()
+ expected = hmac.new(key, (event_id + "." + timestamp + ".").encode("utf-8") + payload, hashlib.sha256).digest()
+ for signature in header("webhook-signature").split():
+ if not signature.startswith("v1,"):
+ continue
+ try:
+ candidate = base64.b64decode(signature[3:], validate=True)
+ except (ValueError, binascii.Error):
+ continue
+ if hmac.compare_digest(expected, candidate):
+ return
+ raise invalid()
diff --git a/tests/fixtures/api-contracts.json b/tests/fixtures/api-contracts.json
index b38d33a..cd17ff7 100644
--- a/tests/fixtures/api-contracts.json
+++ b/tests/fixtures/api-contracts.json
@@ -2036,12 +2036,87 @@
"identity_id"
],
"kind": "model"
+ },
+ {
+ "id": "forward.webhooks.endpoints.create",
+ "method": "POST",
+ "path": "/webhook/endpoints",
+ "required": [
+ "events",
+ "url"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.webhooks.endpoints.list",
+ "method": "GET",
+ "path": "/webhook/endpoints",
+ "required": [],
+ "kind": "page"
+ },
+ {
+ "id": "forward.webhooks.endpoints.retrieve",
+ "method": "GET",
+ "path": "/webhook/endpoints/{endpoint_id}",
+ "required": [
+ "endpoint_id"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.webhooks.endpoints.update",
+ "method": "PUT",
+ "path": "/webhook/endpoints/{endpoint_id}",
+ "required": [
+ "endpoint_id"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.webhooks.endpoints.delete",
+ "method": "DELETE",
+ "path": "/webhook/endpoints/{endpoint_id}",
+ "required": [
+ "endpoint_id"
+ ],
+ "kind": "empty"
+ },
+ {
+ "id": "forward.webhooks.endpoints.enable",
+ "method": "POST",
+ "path": "/webhook/endpoints/{endpoint_id}/enable",
+ "required": [
+ "endpoint_id"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.webhooks.endpoints.disable",
+ "method": "POST",
+ "path": "/webhook/endpoints/{endpoint_id}/disable",
+ "required": [
+ "endpoint_id"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.webhooks.endpoints.test",
+ "method": "POST",
+ "path": "/webhook/endpoints/{endpoint_id}/test",
+ "required": [
+ "endpoint_id"
+ ],
+ "kind": "model"
}
],
"extensions": [
{
"source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)",
"operations": 6
+ },
+ {
+ "source": "https://docs.qoder.com/zh/cloud-agents/api/webhooks/overview (2026-10-22); Forward webhook handler and DTO cross-check",
+ "operations": 8
}
]
}
diff --git a/tests/fixtures/webhook-signature.json b/tests/fixtures/webhook-signature.json
new file mode 100644
index 0000000..0e78223
--- /dev/null
+++ b/tests/fixtures/webhook-signature.json
@@ -0,0 +1,7 @@
+{
+ "secret": "whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
+ "id": "whe_0123456789abcdefghijkl",
+ "timestamp": "1767225600",
+ "payload": "{\"type\":\"session.created\",\"data\":{\"id\":\"sess_01\"}}",
+ "signature": "v1,lMKooyqBqXRe60OmeImJYaDFJ10DGzkw1CMAyVjLbFs="
+}
diff --git a/tests/test_resource_surface.py b/tests/test_resource_surface.py
index 5fd3568..78b03b2 100644
--- a/tests/test_resource_surface.py
+++ b/tests/test_resource_surface.py
@@ -39,7 +39,7 @@ def test_contract_fixture_has_pinned_provenance():
"commit": "bc5bc655d70b67fcd88623320ac01896a7360d8a",
"captured_at": "2026-09-22",
}
- assert len(CONTRACT_FIXTURE["contracts"]) == 221
+ assert len(CONTRACT_FIXTURE["contracts"]) == 229
@pytest.mark.parametrize("endpoint", ENDPOINTS, ids=endpoint_id)
diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py
new file mode 100644
index 0000000..100c80e
--- /dev/null
+++ b/tests/test_webhooks.py
@@ -0,0 +1,151 @@
+import base64
+import inspect
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+import httpx
+import pytest
+
+from qca import AsyncForward, Forward, InternalServerError
+from qca.forward import WebhookVerificationError, verify_webhook_signature
+
+
+async def resolve(value):
+ return await value if inspect.isawaitable(value) else value
+
+
+@pytest.mark.parametrize("async_", [False, True])
+@pytest.mark.parametrize("auth", [{"pat": "token"}, {"auth_mode": "sa", "access_token": "token"}])
+async def test_webhook_lifecycle(async_, auth):
+ requests = []
+
+ def handle(r):
+ requests.append(r)
+ assert r.headers["authorization"] == "Bearer token"
+ if r.method == "POST" and r.url.path.endswith("/endpoints"):
+ assert r.headers["Idempotency-Key"] == "create-once"
+ assert json.loads(r.content) == {"url": "https://hooks.example.com/qca", "events": ["future.event"]}
+ return httpx.Response(201, json={"id": "endpoint", "signing_secret": "synthetic", "future": 1})
+ if r.method == "PUT":
+ assert json.loads(r.content) == {"active": False, "metadata": {"remove": None, "keep": "value"}}
+ return httpx.Response(200, json={"id": "endpoint", "active": False, "last_success_at": None})
+ assert not r.content
+ if r.method == "DELETE":
+ return httpx.Response(204)
+ if r.url.path.endswith("/test"):
+ return httpx.Response(202, json={"event_id": 7, "delivery_rows": 0})
+ return httpx.Response(200, json={"id": "endpoint", "events": ["future.event"], "future": 1})
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle))
+ client = (AsyncForward if async_ else Forward)(http_client=http, **auth)
+ try:
+ resource = client.webhooks.endpoints
+ created = await resolve(
+ resource.create(url="https://hooks.example.com/qca", events=["future.event"], idempotency_key="create-once")
+ )
+ assert created.signing_secret == "synthetic" and "synthetic" not in repr(created)
+ updated = await resolve(
+ resource.update("opaque /?%#", active=False, metadata={"remove": None, "keep": "value"})
+ )
+ assert updated.active is False and updated.last_success_at is None
+ for name in ["retrieve", "enable", "disable"]:
+ endpoint = await resolve(getattr(resource, name)("opaque /?%#"))
+ assert endpoint.events == ["future.event"] and endpoint.model_extra["future"] == 1
+ result = await resolve(resource.test("opaque /?%#"))
+ assert result.delivery_rows == 0
+ assert await resolve(resource.delete("opaque /?%#")) is None
+ for r in requests[1:]:
+ assert "/webhook/endpoints/opaque%20%2F%3F%25%23" in r.url.raw_path.decode()
+ finally:
+ await resolve(client.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+async def test_webhook_cursor_order_and_unsafe_retry(async_):
+ calls = []
+
+ def handle(r):
+ calls.append(r)
+ if r.method == "POST":
+ return httpx.Response(503, json={"error": {"message": "unavailable"}})
+ assert "page" not in r.url.params and r.url.params["limit"] == "2"
+ if len(calls) == 1:
+ return httpx.Response(
+ 200, json={"data": [{"id": "z"}, {"id": "a"}], "last_id": "opaque/+?", "has_more": True}
+ )
+ assert r.url.params["after_id"] == "opaque/+?"
+ return httpx.Response(200, json={"data": [], "last_id": None, "first_id": None, "has_more": False})
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle))
+ client = (AsyncForward if async_ else Forward)(pat="test", http_client=http, max_retries=2)
+ try:
+ page = await resolve(client.webhooks.endpoints.list(limit=2))
+ items = [item async for item in page] if async_ else list(page)
+ assert [i.id for i in items] == ["z", "a"] and len(calls) == 2
+ with pytest.raises(InternalServerError):
+ await resolve(client.webhooks.endpoints.test("id"))
+ assert len(calls) == 3
+ finally:
+ await resolve(client.close())
+
+
+@pytest.fixture
+def vector():
+ return json.loads((Path(__file__).parent / "fixtures/webhook-signature.json").read_text())
+
+
+def test_webhook_signature_vector_and_window(vector):
+ v = vector
+ headers = {
+ "webhook-id": v["id"],
+ "Webhook-Timestamp": v["timestamp"],
+ "Webhook-Signature": "v2,unknown v1,invalid " + v["signature"],
+ }
+ for delta in [-300, 0, 300]:
+ with patch("qca.forward.webhook_signature.time.time", return_value=int(v["timestamp"]) + delta):
+ verify_webhook_signature(v["payload"].encode(), headers, v["secret"])
+ for delta in [-301, 301]:
+ with (
+ patch("qca.forward.webhook_signature.time.time", return_value=int(v["timestamp"]) + delta),
+ pytest.raises(WebhookVerificationError),
+ ):
+ verify_webhook_signature(v["payload"].encode(), headers, v["secret"])
+ with patch("qca.forward.webhook_signature.time.time", return_value=int(v["timestamp"])):
+ for name, value in [
+ ("Webhook-Timestamp", "9223372036854775807"),
+ ("Webhook-Timestamp", "-1"),
+ ("Webhook-Signature", "v2,abc"),
+ ("webhook-id", "other"),
+ ]:
+ with pytest.raises(WebhookVerificationError):
+ verify_webhook_signature(v["payload"].encode(), {**headers, name: value}, v["secret"])
+ with pytest.raises(WebhookVerificationError):
+ verify_webhook_signature((v["payload"] + " ").encode(), headers, v["secret"])
+ for secret in ["", "whsec_", "whsec_bad!", "whsec_" + base64.b64encode(b"too short").decode()]:
+ with pytest.raises(WebhookVerificationError):
+ verify_webhook_signature(v["payload"].encode(), headers, secret)
+
+
+def test_receiver_example_preserves_unknown_and_synthetic_events():
+ import hashlib
+ import hmac
+ import time
+
+ from examples.forward.webhooks import parse_delivery
+
+ key = b"0123456789abcdef0123456789abcdef"
+ secret = "whsec_" + base64.b64encode(key).decode()
+ for event in [
+ {"type": "event", "data": {"type": "future.event", "extra": 42}},
+ {"type": "event", "endpoint_id": "id", "note": "synthetic"},
+ ]:
+ body = json.dumps(event).encode()
+ timestamp = str(int(time.time()))
+ signature = base64.b64encode(
+ hmac.new(key, b"whe_test." + timestamp.encode() + b"." + body, hashlib.sha256).digest()
+ ).decode()
+ headers = {"Webhook-ID": "whe_test", "Webhook-Timestamp": timestamp, "Webhook-Signature": "v1," + signature}
+ assert parse_delivery(body, headers, secret) == event
+ with pytest.raises(WebhookVerificationError):
+ parse_delivery(body + b" ", headers, secret)