From d4468270b9740b7ebb2b02ac1d8a8189eefecd30 Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Thu, 15 May 2025 14:14:16 +0200 Subject: [PATCH 1/6] wip --- src/elevenlabs/webhooks.py | 72 +++++++++++++++++++ tests/test_webhooks.py | 137 +++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 src/elevenlabs/webhooks.py create mode 100644 tests/test_webhooks.py diff --git a/src/elevenlabs/webhooks.py b/src/elevenlabs/webhooks.py new file mode 100644 index 00000000..543caa89 --- /dev/null +++ b/src/elevenlabs/webhooks.py @@ -0,0 +1,72 @@ +import json +import hmac +import hashlib +import time +from typing import Any, Dict + +from .errors import BadRequestError + + +class WebhooksClient: + """ + A client to handle ElevenLabs webhook-related functionality + """ + + def construct_event(self, payload: Any, sig_header: str, secret: str) -> Dict: + """ + Constructs a webhook event object from a payload and signature. + Verifies the webhook signature to ensure the event came from ElevenLabs. + + Args: + payload: The webhook payload (request body) + sig_header: The signature header from the request + secret: Your webhook secret + + Returns: + The verified webhook event + + Raises: + BadRequestError: If the signature is invalid or missing + """ + body = json.dumps(payload) + + if not sig_header: + raise BadRequestError(body="Missing signature header") + + headers = sig_header.split(',') + timestamp = None + signature = None + + for header in headers: + if header.startswith('t='): + timestamp = header[2:] + elif header.startswith('v0='): + signature = header + + if not timestamp or not signature: + raise BadRequestError(body="No signature hash found with expected scheme v0") + + # Validate timestamp + req_timestamp = int(timestamp) * 1000 + tolerance = int(time.time() * 1000) - 30 * 60 * 1000 + if req_timestamp < tolerance: + raise BadRequestError(body="Timestamp outside the tolerance zone") + + # Validate hash + message = f"{timestamp}.{body}" + + if not secret: + raise BadRequestError(body="Webhook secret not configured") + + digest = "v0=" + hmac.new( + secret.encode('utf-8'), + message.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + if signature != digest: + raise BadRequestError( + body="Signature hash does not match the expected signature hash for payload" + ) + + return json.loads(body) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 00000000..b40fca30 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,137 @@ +import json +import time +import hmac +import hashlib +from unittest import mock + +from elevenlabs.client import ElevenLabs +from elevenlabs.errors import BadRequestError + +import pytest + + +def test_construct_event_valid_signature(): + """Test webhook event construction with valid signature.""" + # Setup + client = ElevenLabs() + webhook_secret = "test_secret" + payload = {"event_type": "speech.completed", "id": "123456"} + + # Create a valid signature + body = json.dumps(payload) + timestamp = str(int(time.time())) + message = f"{timestamp}.{body}" + signature = "v0=" + hmac.new( + webhook_secret.encode('utf-8'), + message.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + sig_header = f"t={timestamp},{signature}" + + # Verify event construction + event = client.webhooks.construct_event(payload, sig_header, webhook_secret) + assert event == payload, "Event should match the original payload" + + +def test_construct_event_missing_signature(): + """Test webhook event construction with missing signature header.""" + client = ElevenLabs() + webhook_secret = "test_secret" + payload = {"event_type": "speech.completed", "id": "123456"} + + with pytest.raises(BadRequestError) as excinfo: + client.webhooks.construct_event(payload, "", webhook_secret) + + assert "Missing signature header" in str(excinfo.value) + + +def test_construct_event_invalid_signature_format(): + """Test webhook event construction with invalid signature format.""" + client = ElevenLabs() + webhook_secret = "test_secret" + payload = {"event_type": "speech.completed", "id": "123456"} + sig_header = "invalid_format" + + with pytest.raises(BadRequestError) as excinfo: + client.webhooks.construct_event(payload, sig_header, webhook_secret) + + assert "No signature hash found with expected scheme v0" in str(excinfo.value) + + +def test_construct_event_expired_timestamp(): + """Test webhook event construction with expired timestamp.""" + client = ElevenLabs() + webhook_secret = "test_secret" + payload = {"event_type": "speech.completed", "id": "123456"} + + # Create an expired timestamp (31 minutes old) + expired_time = int(time.time()) - 31 * 60 + timestamp = str(expired_time) + + body = json.dumps(payload) + message = f"{timestamp}.{body}" + signature = "v0=" + hmac.new( + webhook_secret.encode('utf-8'), + message.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + sig_header = f"t={timestamp},{signature}" + + with pytest.raises(BadRequestError) as excinfo: + client.webhooks.construct_event(payload, sig_header, webhook_secret) + + assert "Timestamp outside the tolerance zone" in str(excinfo.value) + + +def test_construct_event_invalid_signature(): + """Test webhook event construction with invalid signature.""" + client = ElevenLabs() + webhook_secret = "test_secret" + payload = {"event_type": "speech.completed", "id": "123456"} + + timestamp = str(int(time.time())) + sig_header = f"t={timestamp},v0=invalid_signature" + + with pytest.raises(BadRequestError) as excinfo: + client.webhooks.construct_event(payload, sig_header, webhook_secret) + + assert "Signature hash does not match" in str(excinfo.value) + + +def test_construct_event_missing_secret(): + """Test webhook event construction with missing secret.""" + client = ElevenLabs() + payload = {"event_type": "speech.completed", "id": "123456"} + + timestamp = str(int(time.time())) + sig_header = f"t={timestamp},v0=some_signature" + + with pytest.raises(BadRequestError) as excinfo: + client.webhooks.construct_event(payload, sig_header, "") + + assert "Webhook secret not configured" in str(excinfo.value) + + +@mock.patch('time.time') +def test_construct_event_mocked_time(mock_time): + """Test webhook event construction with mocked time.""" + mock_time.return_value = 1600000000 + + client = ElevenLabs() + webhook_secret = "test_secret" + payload = {"event_type": "speech.completed", "id": "123456"} + + # Create a valid signature with fixed timestamp + body = json.dumps(payload) + timestamp = "1600000000" + message = f"{timestamp}.{body}" + signature = "v0=" + hmac.new( + webhook_secret.encode('utf-8'), + message.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + sig_header = f"t={timestamp},{signature}" + + # Verify event construction + event = client.webhooks.construct_event(payload, sig_header, webhook_secret) + assert event == payload, "Event should match the original payload" From bc3634d2542412a2bb0c7be9d0d8328a47b35b0d Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Thu, 15 May 2025 16:17:37 +0200 Subject: [PATCH 2/6] Add webhook helper --- src/elevenlabs/client.py | 25 +++++++++++++++++++++++++ src/elevenlabs/webhooks.py | 1 - 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/elevenlabs/client.py b/src/elevenlabs/client.py index 45552a70..685d4734 100644 --- a/src/elevenlabs/client.py +++ b/src/elevenlabs/client.py @@ -17,6 +17,7 @@ from .environment import ElevenLabsEnvironment from .realtime_tts import RealtimeTextToSpeechClient from .types import OutputFormat +from .webhooks import WebhooksClient DEFAULT_VOICE = Voice( @@ -122,6 +123,7 @@ def __init__( httpx_client=httpx_client ) self.text_to_speech = RealtimeTextToSpeechClient(client_wrapper=self._client_wrapper) + self.webhooks = WebhooksClient() @deprecated def clone( @@ -303,6 +305,29 @@ class AsyncElevenLabs(AsyncBaseElevenLabs): ) """ + def __init__( + self, + *, + base_url: typing.Optional[str] = None, + environment: ElevenLabsEnvironment = ElevenLabsEnvironment.PRODUCTION, + api_key: typing.Optional[str] = os.getenv("ELEVENLABS_API_KEY"), + timeout: typing.Optional[float] = 60, + httpx_client: typing.Optional[httpx.AsyncClient] = None + ): + super().__init__( + environment=base_url + and ElevenLabsEnvironment( + base=f"https://{get_base_url_host(base_url)}", + wss=f"wss://{get_base_url_host(base_url)}", + ) + or environment, + api_key=api_key, + timeout=timeout, + httpx_client=httpx_client + ) + self.text_to_speech = RealtimeTextToSpeechClient(client_wrapper=self._client_wrapper) + self.webhooks = WebhooksClient() + @deprecated_async async def clone( self, diff --git a/src/elevenlabs/webhooks.py b/src/elevenlabs/webhooks.py index 543caa89..8f00936e 100644 --- a/src/elevenlabs/webhooks.py +++ b/src/elevenlabs/webhooks.py @@ -3,7 +3,6 @@ import hashlib import time from typing import Any, Dict - from .errors import BadRequestError From 1a98dbade4ac64e99c7aa4cec0630165fab3216e Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Thu, 15 May 2025 16:40:40 +0200 Subject: [PATCH 3/6] Fix --- src/elevenlabs/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/elevenlabs/client.py b/src/elevenlabs/client.py index 685d4734..3799e768 100644 --- a/src/elevenlabs/client.py +++ b/src/elevenlabs/client.py @@ -325,7 +325,6 @@ def __init__( timeout=timeout, httpx_client=httpx_client ) - self.text_to_speech = RealtimeTextToSpeechClient(client_wrapper=self._client_wrapper) self.webhooks = WebhooksClient() @deprecated_async From 2e267f973a1790eccfd1d458f64c88538f82f9ed Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Fri, 16 May 2025 14:07:14 +0200 Subject: [PATCH 4/6] Fail fast --- src/elevenlabs/webhooks.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/elevenlabs/webhooks.py b/src/elevenlabs/webhooks.py index 8f00936e..dcc707c0 100644 --- a/src/elevenlabs/webhooks.py +++ b/src/elevenlabs/webhooks.py @@ -11,13 +11,13 @@ class WebhooksClient: A client to handle ElevenLabs webhook-related functionality """ - def construct_event(self, payload: Any, sig_header: str, secret: str) -> Dict: + def construct_event(self, rawBody: str, sig_header: str, secret: str) -> Dict: """ Constructs a webhook event object from a payload and signature. Verifies the webhook signature to ensure the event came from ElevenLabs. Args: - payload: The webhook payload (request body) + rawBody: The webhook request body. Must be the raw body, not a JSON object sig_header: The signature header from the request secret: Your webhook secret @@ -27,11 +27,13 @@ def construct_event(self, payload: Any, sig_header: str, secret: str) -> Dict: Raises: BadRequestError: If the signature is invalid or missing """ - body = json.dumps(payload) if not sig_header: raise BadRequestError(body="Missing signature header") + if not secret: + raise BadRequestError(body="Webhook secret not configured") + headers = sig_header.split(',') timestamp = None signature = None @@ -52,10 +54,7 @@ def construct_event(self, payload: Any, sig_header: str, secret: str) -> Dict: raise BadRequestError(body="Timestamp outside the tolerance zone") # Validate hash - message = f"{timestamp}.{body}" - - if not secret: - raise BadRequestError(body="Webhook secret not configured") + message = f"{timestamp}.{rawBody}" digest = "v0=" + hmac.new( secret.encode('utf-8'), @@ -68,4 +67,4 @@ def construct_event(self, payload: Any, sig_header: str, secret: str) -> Dict: body="Signature hash does not match the expected signature hash for payload" ) - return json.loads(body) + return json.loads(rawBody) From 38ed60a4c3bba91a6f594c187923418de6cdcfa9 Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Fri, 16 May 2025 14:13:19 +0200 Subject: [PATCH 5/6] Fix test --- tests/test_webhooks.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index b40fca30..7282fdd8 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -29,7 +29,7 @@ def test_construct_event_valid_signature(): sig_header = f"t={timestamp},{signature}" # Verify event construction - event = client.webhooks.construct_event(payload, sig_header, webhook_secret) + event = client.webhooks.construct_event(body, sig_header, webhook_secret) assert event == payload, "Event should match the original payload" @@ -50,10 +50,11 @@ def test_construct_event_invalid_signature_format(): client = ElevenLabs() webhook_secret = "test_secret" payload = {"event_type": "speech.completed", "id": "123456"} + body = json.dumps(payload) sig_header = "invalid_format" with pytest.raises(BadRequestError) as excinfo: - client.webhooks.construct_event(payload, sig_header, webhook_secret) + client.webhooks.construct_event(body, sig_header, webhook_secret) assert "No signature hash found with expected scheme v0" in str(excinfo.value) @@ -78,7 +79,7 @@ def test_construct_event_expired_timestamp(): sig_header = f"t={timestamp},{signature}" with pytest.raises(BadRequestError) as excinfo: - client.webhooks.construct_event(payload, sig_header, webhook_secret) + client.webhooks.construct_event(body, sig_header, webhook_secret) assert "Timestamp outside the tolerance zone" in str(excinfo.value) @@ -88,12 +89,13 @@ def test_construct_event_invalid_signature(): client = ElevenLabs() webhook_secret = "test_secret" payload = {"event_type": "speech.completed", "id": "123456"} + body = json.dumps(payload) timestamp = str(int(time.time())) sig_header = f"t={timestamp},v0=invalid_signature" with pytest.raises(BadRequestError) as excinfo: - client.webhooks.construct_event(payload, sig_header, webhook_secret) + client.webhooks.construct_event(body, sig_header, webhook_secret) assert "Signature hash does not match" in str(excinfo.value) @@ -102,12 +104,13 @@ def test_construct_event_missing_secret(): """Test webhook event construction with missing secret.""" client = ElevenLabs() payload = {"event_type": "speech.completed", "id": "123456"} + body = json.dumps(payload) timestamp = str(int(time.time())) sig_header = f"t={timestamp},v0=some_signature" with pytest.raises(BadRequestError) as excinfo: - client.webhooks.construct_event(payload, sig_header, "") + client.webhooks.construct_event(body, sig_header, "") assert "Webhook secret not configured" in str(excinfo.value) @@ -133,5 +136,5 @@ def test_construct_event_mocked_time(mock_time): sig_header = f"t={timestamp},{signature}" # Verify event construction - event = client.webhooks.construct_event(payload, sig_header, webhook_secret) + event = client.webhooks.construct_event(body, sig_header, webhook_secret) assert event == payload, "Event should match the original payload" From 01e0f63f570e28d46a128bce89bb100f7a1e4245 Mon Sep 17 00:00:00 2001 From: Paul Asjes Date: Mon, 19 May 2025 14:36:30 +0200 Subject: [PATCH 6/6] Address feedback --- src/elevenlabs/client.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/elevenlabs/client.py b/src/elevenlabs/client.py index 3799e768..d204c52a 100644 --- a/src/elevenlabs/client.py +++ b/src/elevenlabs/client.py @@ -112,12 +112,10 @@ def __init__( httpx_client: typing.Optional[httpx.Client] = None ): super().__init__( - environment=base_url - and ElevenLabsEnvironment( + environment=ElevenLabsEnvironment( base=f"https://{get_base_url_host(base_url)}", wss=f"wss://{get_base_url_host(base_url)}", - ) - or environment, + ) if base_url else environment, api_key=api_key, timeout=timeout, httpx_client=httpx_client