From b8284ff8d9ecf66305ac85ba056c0b17d25b9872 Mon Sep 17 00:00:00 2001 From: infinityplusone Date: Wed, 21 Jan 2026 08:26:05 -0500 Subject: [PATCH] Implement Webhooks v2 support with CRUD operations for subscriptions and endpoints, including event type discovery and test delivery functionality. Update documentation and README to reflect new features. --- .gitignore | 3 + CHANGELOG.md | 12 +- README.md | 2 +- docs/API_REFERENCE.md | 130 ++++++++++++++++++ tango/__init__.py | 12 ++ tango/client.py | 298 ++++++++++++++++++++++++++++++++++++++++++ tango/models.py | 55 ++++++++ tests/test_client.py | 254 +++++++++++++++++++++++++++++++++++ 8 files changed, 764 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index fd11622..7aa56cb 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ dmypy.json # OS .DS_Store Thumbs.db + +# Other +yoni/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f00873c..94cd401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Webhooks v2 client support: event type discovery, subscription CRUD, endpoint management, test delivery, and sample payload helpers. (refs `makegov/tango#1274`) + +### Changed + +- HTTP client now supports PATCH/DELETE helpers for webhook management endpoints. + ## [0.2.0] - 2025-11-16 -- Entirely refactored SDK \ No newline at end of file +- Entirely refactored SDK diff --git a/README.md b/README.md index 9e26b14..3438105 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A modern Python SDK for the [Tango API](https://tango.makegov.com) by MakeGov, f - **Dynamic Response Shaping** - Request only the fields you need, reducing payload sizes by 60-80% - **Full Type Safety** - Runtime-generated TypedDict types with accurate type hints for IDE autocomplete -- **Comprehensive API Coverage** - All major Tango API endpoints (contracts, entities, forecasts, opportunities, notices, grants) [Note: the current version does NOT implement all endpoints, we will be adding them incrementally] +- **Comprehensive API Coverage** - All major Tango API endpoints (contracts, entities, forecasts, opportunities, notices, grants, webhooks) [Note: the current version does NOT implement all endpoints, we will be adding them incrementally] - **Flexible Data Access** - Dictionary-based response objects with validation - **Modern Python** - Built for Python 3.12+ using modern async-ready patterns - **Production-Ready** - Comprehensive test suite with VCR.py-based integration tests diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 6c31764..f210c23 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -13,6 +13,7 @@ Complete reference for all Tango Python SDK methods and functionality. - [Notices](#notices) - [Grants](#grants) - [Business Types](#business-types) +- [Webhooks](#webhooks) - [Response Objects](#response-objects) - [Error Handling](#error-handling) @@ -606,6 +607,135 @@ for biz_type in business_types.results: --- +## Webhooks + +Webhook APIs let **Large / Enterprise** users manage subscription filters for outbound Tango webhooks. + +### list_webhook_event_types() + +Discover supported `event_type` values and subject types. + +```python +info = client.list_webhook_event_types() +print(info.event_types[0].event_type) +``` + +### list_webhook_subscriptions() + +```python +subs = client.list_webhook_subscriptions(page=1, page_size=25) +``` + +Notes: + +- This endpoint uses `page` + `page_size` (tier-capped) rather than `limit`. + +### create_webhook_subscription() + +```python +sub = client.create_webhook_subscription( + "Track specific vendors", + { + "records": [ + {"event_type": "awards.new_award", "subject_type": "entity", "subject_ids": ["UEI123ABC"]}, + {"event_type": "awards.new_transaction", "subject_type": "entity", "subject_ids": ["UEI123ABC"]}, + ] + }, +) +``` + +Notes: + +- Prefer v2 fields: `subject_type` + `subject_ids`. +- Legacy compatibility: `resource_ids` is accepted as an alias for `subject_ids` (don’t send both). +- Catch-all: `subject_ids: []` means “all subjects” for that record and is **Enterprise-only**. Large tier users must list specific IDs. + +### update_webhook_subscription() + +```python +sub = client.update_webhook_subscription("SUBSCRIPTION_UUID", subscription_name="Updated name") +``` + +### delete_webhook_subscription() + +```python +client.delete_webhook_subscription("SUBSCRIPTION_UUID") +``` + +### list_webhook_endpoints() + +List your webhook endpoint(s). + +```python +endpoints = client.list_webhook_endpoints(page=1, limit=25) +``` + +### get_webhook_endpoint() + +```python +endpoint = client.get_webhook_endpoint("ENDPOINT_UUID") +``` + +### create_webhook_endpoint() / update_webhook_endpoint() / delete_webhook_endpoint() + +In production, MakeGov provisions the initial endpoint for you. These are most useful for dev/self-service. + +```python +endpoint = client.create_webhook_endpoint("https://example.com/tango/webhooks") +endpoint = client.update_webhook_endpoint(endpoint.id, is_active=False) +client.delete_webhook_endpoint(endpoint.id) +``` + +### test_webhook_delivery() + +Send an immediate test webhook to your configured endpoint. + +```python +result = client.test_webhook_delivery() +print(result.success, result.status_code) +``` + +### get_webhook_sample_payload() + +Fetch Tango-shaped sample deliveries (and sample subscription request bodies). + +```python +sample = client.get_webhook_sample_payload(event_type="awards.new_award") +print(sample["event_type"]) +``` + +### Deliveries / redelivery + +The API does not currently expose a public `/api/webhooks/deliveries/` or redelivery endpoint. Use: + +- `test_webhook_delivery()` for connectivity checks +- `get_webhook_sample_payload()` for building handlers + subscription payloads + +### Receiving webhooks (signature verification) + +Every delivery includes an HMAC signature header: + +- `X-Tango-Signature: sha256=` + +Compute the digest over the **raw request body bytes** using your shared secret. + +```python +import hashlib +import hmac + + +def verify_tango_webhook_signature(secret: str, raw_body: bytes, signature_header: str | None) -> bool: + if not signature_header: + return False + sig = signature_header.strip() + if sig.startswith("sha256="): + sig = sig[len("sha256=") :] + expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, sig) +``` + +--- + ## Response Objects ### PaginatedResponse diff --git a/tango/__init__.py b/tango/__init__.py index 995f02a..8258721 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -12,6 +12,12 @@ PaginatedResponse, SearchFilters, ShapeConfig, + WebhookEndpoint, + WebhookEventType, + WebhookEventTypesResponse, + WebhookSubjectTypeDefinition, + WebhookSubscription, + WebhookTestDeliveryResult, ) from .shapes import ( ModelFactory, @@ -31,6 +37,12 @@ "PaginatedResponse", "SearchFilters", "ShapeConfig", + "WebhookEndpoint", + "WebhookEventType", + "WebhookEventTypesResponse", + "WebhookSubscription", + "WebhookSubjectTypeDefinition", + "WebhookTestDeliveryResult", "ShapeParser", "ModelFactory", "TypeGenerator", diff --git a/tango/client.py b/tango/client.py index cd0efac..d59a883 100644 --- a/tango/client.py +++ b/tango/client.py @@ -28,6 +28,12 @@ PaginatedResponse, SearchFilters, ShapeConfig, + WebhookEndpoint, + WebhookEventType, + WebhookEventTypesResponse, + WebhookSubjectTypeDefinition, + WebhookSubscription, + WebhookTestDeliveryResult, ) from tango.shapes import ( ModelFactory, @@ -141,6 +147,14 @@ def _post(self, endpoint: str, json_data: dict[str, Any]) -> dict[str, Any]: """Make a POST request""" return self._request("POST", endpoint, json_data=json_data) + def _patch(self, endpoint: str, json_data: dict[str, Any]) -> dict[str, Any]: + """Make a PATCH request""" + return self._request("PATCH", endpoint, json_data=json_data) + + def _delete(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """Make a DELETE request""" + return self._request("DELETE", endpoint, params=params) + # ============================================================================ # Shape Parsing Utilities # ============================================================================ @@ -858,3 +872,287 @@ def list_grants( previous=data.get("previous"), results=results, ) + + # ============================================================================ + # Webhooks (v2) + # ============================================================================ + + def list_webhook_event_types(self) -> WebhookEventTypesResponse: + """Discover supported webhook event types and subject types.""" + data = self._get("/api/webhooks/event-types/") + + event_types = [ + WebhookEventType( + event_type=str(e.get("event_type", "")), + default_subject_type=str(e.get("default_subject_type", "")), + description=str(e.get("description", "")), + schema_version=int(e.get("schema_version", 1)), + ) + for e in (data.get("event_types") or []) + if isinstance(e, dict) + ] + + subject_types = [str(x) for x in (data.get("subject_types") or [])] + + subject_type_definitions = [ + WebhookSubjectTypeDefinition( + subject_type=str(d.get("subject_type", "")), + description=str(d.get("description", "")), + id_format=str(d.get("id_format", "")), + status=str(d.get("status", "active")), + ) + for d in (data.get("subject_type_definitions") or []) + if isinstance(d, dict) + ] + + return WebhookEventTypesResponse( + event_types=event_types, + subject_types=subject_types, + subject_type_definitions=subject_type_definitions, + ) + + def list_webhook_subscriptions( + self, page: int = 1, page_size: int | None = None + ) -> PaginatedResponse[WebhookSubscription]: + """ + List webhook subscriptions for the authenticated user's endpoint. + + Notes: + - This endpoint uses `page` + `page_size` (tier-capped) rather than `limit`. + """ + params: dict[str, Any] = {"page": page} + if page_size is not None: + params["page_size"] = page_size + + data = self._get("/api/webhooks/subscriptions/", params) + results = [ + WebhookSubscription( + id=str(item.get("id", "")), + endpoint=str(item.get("endpoint")) if item.get("endpoint") is not None else None, + subscription_name=str(item.get("subscription_name", "")), + payload=item.get("payload"), + created_at=str(item.get("created_at", "")), + ) + for item in (data.get("results") or []) + if isinstance(item, dict) + ] + + return PaginatedResponse( + count=int(data.get("count", len(results))), + next=data.get("next"), + previous=data.get("previous"), + results=results, + ) + + def get_webhook_subscription(self, subscription_id: str) -> WebhookSubscription: + """Get a single webhook subscription by id (UUID).""" + if not subscription_id: + raise TangoValidationError("Webhook subscription_id is required") + + data = self._get(f"/api/webhooks/subscriptions/{subscription_id}/") + return WebhookSubscription( + id=str(data.get("id", "")), + endpoint=str(data.get("endpoint")) if data.get("endpoint") is not None else None, + subscription_name=str(data.get("subscription_name", "")), + payload=data.get("payload"), + created_at=str(data.get("created_at", "")), + ) + + def create_webhook_subscription( + self, subscription_name: str, payload: dict[str, Any] + ) -> WebhookSubscription: + """Create a webhook subscription.""" + if not subscription_name: + raise TangoValidationError("Webhook subscription_name is required") + + data = self._post( + "/api/webhooks/subscriptions/", + {"subscription_name": subscription_name, "payload": payload}, + ) + + return WebhookSubscription( + id=str(data.get("id", "")), + endpoint=str(data.get("endpoint")) if data.get("endpoint") is not None else None, + subscription_name=str(data.get("subscription_name", "")), + payload=data.get("payload"), + created_at=str(data.get("created_at", "")), + ) + + def update_webhook_subscription( + self, + subscription_id: str, + *, + subscription_name: str | None = None, + payload: dict[str, Any] | None = None, + ) -> WebhookSubscription: + """Patch a webhook subscription.""" + if not subscription_id: + raise TangoValidationError("Webhook subscription_id is required") + + body: dict[str, Any] = {} + if subscription_name is not None: + body["subscription_name"] = subscription_name + if payload is not None: + body["payload"] = payload + + data = self._patch(f"/api/webhooks/subscriptions/{subscription_id}/", body) + return WebhookSubscription( + id=str(data.get("id", "")), + endpoint=str(data.get("endpoint")) if data.get("endpoint") is not None else None, + subscription_name=str(data.get("subscription_name", "")), + payload=data.get("payload"), + created_at=str(data.get("created_at", "")), + ) + + def delete_webhook_subscription(self, subscription_id: str) -> None: + """Delete a webhook subscription.""" + if not subscription_id: + raise TangoValidationError("Webhook subscription_id is required") + self._delete(f"/api/webhooks/subscriptions/{subscription_id}/") + + def get_webhook_endpoint(self, endpoint_id: str) -> WebhookEndpoint: + """Get a webhook endpoint by id (UUID).""" + if not endpoint_id: + raise TangoValidationError("Webhook endpoint_id is required") + data = self._get(f"/api/webhooks/endpoints/{endpoint_id}/") + return WebhookEndpoint( + id=str(data.get("id", "")), + name=str(data.get("name", "")), + callback_url=str(data.get("callback_url", "")), + secret=str(data.get("secret")) if data.get("secret") is not None else None, + is_active=bool(data.get("is_active", False)), + created_at=str(data.get("created_at", "")), + updated_at=str(data.get("updated_at", "")), + ) + + def list_webhook_endpoints( + self, page: int = 1, limit: int = 25 + ) -> PaginatedResponse[WebhookEndpoint]: + """ + List webhook endpoints accessible to the authenticated user. + + Notes: + - In typical production usage, MakeGov provisions the initial endpoint for you. + - The API is opinionated: endpoints are "owned" by you (name == username/email). + """ + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + data = self._get("/api/webhooks/endpoints/", params) + + results = [ + WebhookEndpoint( + id=str(item.get("id", "")), + name=str(item.get("name", "")), + callback_url=str(item.get("callback_url", "")), + secret=str(item.get("secret")) if item.get("secret") is not None else None, + is_active=bool(item.get("is_active", False)), + created_at=str(item.get("created_at", "")), + updated_at=str(item.get("updated_at", "")), + ) + for item in (data.get("results") or []) + if isinstance(item, dict) + ] + + return PaginatedResponse( + count=int(data.get("count", len(results))), + next=data.get("next"), + previous=data.get("previous"), + results=results, + ) + + def create_webhook_endpoint(self, callback_url: str, is_active: bool = True) -> WebhookEndpoint: + """ + Create a webhook endpoint for the authenticated user. + + Note: + - The server generates `secret` and manages `name`. + - Only one endpoint per user is allowed; if one already exists, this will fail. + """ + if not callback_url: + raise TangoValidationError("Webhook callback_url is required") + + data = self._post( + "/api/webhooks/endpoints/", + {"callback_url": callback_url, "is_active": is_active}, + ) + return WebhookEndpoint( + id=str(data.get("id", "")), + name=str(data.get("name", "")), + callback_url=str(data.get("callback_url", "")), + secret=str(data.get("secret")) if data.get("secret") is not None else None, + is_active=bool(data.get("is_active", False)), + created_at=str(data.get("created_at", "")), + updated_at=str(data.get("updated_at", "")), + ) + + def update_webhook_endpoint( + self, + endpoint_id: str, + *, + callback_url: str | None = None, + is_active: bool | None = None, + ) -> WebhookEndpoint: + """Patch a webhook endpoint.""" + if not endpoint_id: + raise TangoValidationError("Webhook endpoint_id is required") + + body: dict[str, Any] = {} + if callback_url is not None: + body["callback_url"] = callback_url + if is_active is not None: + body["is_active"] = is_active + + data = self._patch(f"/api/webhooks/endpoints/{endpoint_id}/", body) + return WebhookEndpoint( + id=str(data.get("id", "")), + name=str(data.get("name", "")), + callback_url=str(data.get("callback_url", "")), + secret=str(data.get("secret")) if data.get("secret") is not None else None, + is_active=bool(data.get("is_active", False)), + created_at=str(data.get("created_at", "")), + updated_at=str(data.get("updated_at", "")), + ) + + def delete_webhook_endpoint(self, endpoint_id: str) -> None: + """Delete a webhook endpoint.""" + if not endpoint_id: + raise TangoValidationError("Webhook endpoint_id is required") + self._delete(f"/api/webhooks/endpoints/{endpoint_id}/") + + def test_webhook_delivery(self, endpoint_id: str | None = None) -> WebhookTestDeliveryResult: + """ + Send an immediate test webhook to your endpoint. + + If endpoint_id is not provided, the server will use your default endpoint. + """ + body: dict[str, Any] = {} + if endpoint_id: + body["endpoint_id"] = endpoint_id + data = self._post("/api/webhooks/endpoints/test-delivery/", body) + return WebhookTestDeliveryResult( + success=bool(data.get("success", False)), + status_code=int(data["status_code"]) if data.get("status_code") is not None else None, + response_time_ms=int(data["response_time_ms"]) + if data.get("response_time_ms") is not None + else None, + endpoint_url=str(data.get("endpoint_url")) + if data.get("endpoint_url") is not None + else None, + message=str(data.get("message")) if data.get("message") is not None else None, + error=str(data.get("error")) if data.get("error") is not None else None, + response_body=str(data.get("response_body")) + if data.get("response_body") is not None + else None, + test_payload=data.get("test_payload"), + ) + + def get_webhook_sample_payload(self, event_type: str | None = None) -> dict[str, Any]: + """ + Fetch Tango-shaped sample webhook deliveries. + + - If event_type is provided, returns the single-event response. + - Otherwise returns a `samples` mapping for all supported event types. + """ + params: dict[str, Any] = {} + if event_type: + params["event_type"] = event_type + return self._get("/api/webhooks/endpoints/sample-payload/", params) diff --git a/tango/models.py b/tango/models.py index 800e4d0..1630e00 100644 --- a/tango/models.py +++ b/tango/models.py @@ -377,6 +377,61 @@ class APIKey: created_at: str | None = None +@dataclass +class WebhookEventType: + event_type: str + default_subject_type: str + description: str + schema_version: int + + +@dataclass +class WebhookSubjectTypeDefinition: + subject_type: str + description: str + id_format: str + status: str + + +@dataclass +class WebhookEventTypesResponse: + event_types: list[WebhookEventType] + subject_types: list[str] + subject_type_definitions: list[WebhookSubjectTypeDefinition] + + +@dataclass +class WebhookSubscription: + id: str + subscription_name: str + payload: dict[str, Any] | None + created_at: str + endpoint: str | None = None + + +@dataclass +class WebhookEndpoint: + id: str + name: str + callback_url: str + is_active: bool + created_at: str + updated_at: str + secret: str | None = None + + +@dataclass +class WebhookTestDeliveryResult: + success: bool + status_code: int | None = None + response_time_ms: int | None = None + endpoint_url: str | None = None + message: str | None = None + error: str | None = None + response_body: str | None = None + test_payload: dict[str, Any] | None = None + + @dataclass class PaginatedResponse[T]: """Paginated API response diff --git a/tests/test_client.py b/tests/test_client.py index 5f35396..e727c34 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -209,6 +209,260 @@ def test_client_initialization_always_has_dynamic_models(self): assert client._type_generator is not None assert client._model_factory is not None + +class TestWebhooksEndpoints: + @patch("tango.client.httpx.Client.request") + def test_list_webhook_event_types(self, mock_request): + mock_response = Mock() + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.json.return_value = { + "event_types": [ + { + "event_type": "awards.new_award", + "default_subject_type": "entity", + "description": "", + "schema_version": 1, + } + ], + "subject_types": ["entity"], + "subject_type_definitions": [ + { + "subject_type": "entity", + "description": "Entity UEI", + "id_format": "UEI", + "status": "active", + } + ], + } + mock_response.content = b'{"event_types": []}' + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + resp = client.list_webhook_event_types() + + assert resp.event_types[0].event_type == "awards.new_award" + assert resp.subject_types == ["entity"] + assert resp.subject_type_definitions[0].subject_type == "entity" + + call_args = mock_request.call_args + assert call_args[1]["method"] == "GET" + assert call_args[1]["url"].endswith("/api/webhooks/event-types/") + + @patch("tango.client.httpx.Client.request") + def test_webhook_subscriptions_crud(self, mock_request): + client = TangoClient(api_key="test-key", base_url="https://example.test") + + # list + list_response = Mock() + list_response.is_success = True + list_response.status_code = 200 + list_response.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": "sub-1", + "endpoint": "endpoint-1", + "subscription_name": "My sub", + "payload": {"records": []}, + "created_at": "2026-01-01T00:00:00Z", + } + ], + } + list_response.content = b'{"count": 1}' + + # create + create_response = Mock() + create_response.is_success = True + create_response.status_code = 201 + create_response.json.return_value = { + "id": "sub-1", + "endpoint": "endpoint-1", + "subscription_name": "My sub", + "payload": {"records": []}, + "created_at": "2026-01-01T00:00:00Z", + } + create_response.content = b'{"id": "sub-1"}' + + # update + update_response = Mock() + update_response.is_success = True + update_response.status_code = 200 + update_response.json.return_value = { + "id": "sub-1", + "endpoint": "endpoint-1", + "subscription_name": "Updated", + "payload": {"records": []}, + "created_at": "2026-01-01T00:00:00Z", + } + update_response.content = b'{"id": "sub-1"}' + + # delete (204, empty content) + delete_response = Mock() + delete_response.is_success = True + delete_response.status_code = 204 + delete_response.content = b"" + + mock_request.side_effect = [ + list_response, + create_response, + update_response, + delete_response, + ] + + subs = client.list_webhook_subscriptions(page=2, page_size=25) + assert subs.count == 1 + assert subs.results[0].subscription_name == "My sub" + + created = client.create_webhook_subscription("My sub", {"records": []}) + assert created.id == "sub-1" + + updated = client.update_webhook_subscription("sub-1", subscription_name="Updated") + assert updated.subscription_name == "Updated" + + client.delete_webhook_subscription("sub-1") + + # Ensure correct request params/bodies were used + calls = mock_request.call_args_list + assert calls[0][1]["method"] == "GET" + assert calls[0][1]["params"]["page"] == 2 + assert calls[0][1]["params"]["page_size"] == 25 + + assert calls[1][1]["method"] == "POST" + assert calls[1][1]["json"]["subscription_name"] == "My sub" + assert calls[1][1]["json"]["payload"] == {"records": []} + + assert calls[2][1]["method"] == "PATCH" + assert calls[2][1]["json"]["subscription_name"] == "Updated" + + assert calls[3][1]["method"] == "DELETE" + + @patch("tango.client.httpx.Client.request") + def test_webhook_test_delivery_and_sample_payload(self, mock_request): + client = TangoClient(api_key="test-key", base_url="https://example.test") + + test_delivery_response = Mock() + test_delivery_response.is_success = True + test_delivery_response.status_code = 200 + test_delivery_response.json.return_value = { + "success": True, + "status_code": 200, + "message": "ok", + } + test_delivery_response.content = b'{"success": true}' + + sample_response = Mock() + sample_response.is_success = True + sample_response.status_code = 200 + sample_response.json.return_value = { + "event_type": "awards.new_award", + "sample_delivery": {"timestamp": "2026-01-01T00:00:00Z", "events": [{"event_type": "awards.new_award"}]}, + } + sample_response.content = b'{"event_type": "awards.new_award"}' + + mock_request.side_effect = [test_delivery_response, sample_response] + + result = client.test_webhook_delivery() + assert result.success is True + + sample = client.get_webhook_sample_payload(event_type="awards.new_award") + assert sample["event_type"] == "awards.new_award" + + calls = mock_request.call_args_list + assert calls[0][1]["method"] == "POST" + assert calls[0][1]["url"].endswith("/api/webhooks/endpoints/test-delivery/") + assert calls[0][1]["json"] == {} + + assert calls[1][1]["method"] == "GET" + assert calls[1][1]["url"].endswith("/api/webhooks/endpoints/sample-payload/") + assert calls[1][1]["params"]["event_type"] == "awards.new_award" + + @patch("tango.client.httpx.Client.request") + def test_webhook_endpoints_crud(self, mock_request): + client = TangoClient(api_key="test-key", base_url="https://example.test") + + list_response = Mock() + list_response.is_success = True + list_response.status_code = 200 + list_response.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": "ep-1", + "name": "yoni", + "callback_url": "https://example.com/tango/webhooks", + "is_active": True, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + ], + } + list_response.content = b'{"count": 1}' + + create_response = Mock() + create_response.is_success = True + create_response.status_code = 201 + create_response.json.return_value = { + "id": "ep-1", + "name": "yoni", + "callback_url": "https://example.com/tango/webhooks", + "secret": "secret", + "is_active": True, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + create_response.content = b'{"id": "ep-1"}' + + update_response = Mock() + update_response.is_success = True + update_response.status_code = 200 + update_response.json.return_value = { + "id": "ep-1", + "name": "yoni", + "callback_url": "https://example.com/tango/webhooks", + "is_active": False, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + } + update_response.content = b'{"id": "ep-1"}' + + delete_response = Mock() + delete_response.is_success = True + delete_response.status_code = 204 + delete_response.content = b"" + + mock_request.side_effect = [list_response, create_response, update_response, delete_response] + + endpoints = client.list_webhook_endpoints(page=2, limit=10) + assert endpoints.count == 1 + assert endpoints.results[0].name == "yoni" + + created = client.create_webhook_endpoint("https://example.com/tango/webhooks", is_active=True) + assert created.secret == "secret" + + updated = client.update_webhook_endpoint("ep-1", is_active=False) + assert updated.is_active is False + + client.delete_webhook_endpoint("ep-1") + + calls = mock_request.call_args_list + assert calls[0][1]["method"] == "GET" + assert calls[0][1]["params"]["page"] == 2 + assert calls[0][1]["params"]["limit"] == 10 + + assert calls[1][1]["method"] == "POST" + assert calls[1][1]["json"]["callback_url"] == "https://example.com/tango/webhooks" + assert calls[1][1]["json"]["is_active"] is True + + assert calls[2][1]["method"] == "PATCH" + assert calls[2][1]["json"]["is_active"] is False + + assert calls[3][1]["method"] == "DELETE" + @patch("tango.client.httpx.Client.request") def test_list_contracts_returns_dynamic_models(self, mock_request): """Test list_contracts always returns dynamic models"""