Skip to content

Commit a91df35

Browse files
authored
Add webhook verification helper (#552)
* wip * Add webhook helper * Fix * Fail fast * Fix test * Address feedback
1 parent af1c269 commit a91df35

File tree

3 files changed

+236
-4
lines changed

3 files changed

+236
-4
lines changed

src/elevenlabs/client.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .environment import ElevenLabsEnvironment
1818
from .realtime_tts import RealtimeTextToSpeechClient
1919
from .types import OutputFormat
20+
from .webhooks import WebhooksClient
2021

2122

2223
DEFAULT_VOICE = Voice(
@@ -111,17 +112,16 @@ def __init__(
111112
httpx_client: typing.Optional[httpx.Client] = None
112113
):
113114
super().__init__(
114-
environment=base_url
115-
and ElevenLabsEnvironment(
115+
environment=ElevenLabsEnvironment(
116116
base=f"https://{get_base_url_host(base_url)}",
117117
wss=f"wss://{get_base_url_host(base_url)}",
118-
)
119-
or environment,
118+
) if base_url else environment,
120119
api_key=api_key,
121120
timeout=timeout,
122121
httpx_client=httpx_client
123122
)
124123
self.text_to_speech = RealtimeTextToSpeechClient(client_wrapper=self._client_wrapper)
124+
self.webhooks = WebhooksClient()
125125

126126
@deprecated
127127
def clone(
@@ -303,6 +303,28 @@ class AsyncElevenLabs(AsyncBaseElevenLabs):
303303
)
304304
"""
305305

306+
def __init__(
307+
self,
308+
*,
309+
base_url: typing.Optional[str] = None,
310+
environment: ElevenLabsEnvironment = ElevenLabsEnvironment.PRODUCTION,
311+
api_key: typing.Optional[str] = os.getenv("ELEVENLABS_API_KEY"),
312+
timeout: typing.Optional[float] = 60,
313+
httpx_client: typing.Optional[httpx.AsyncClient] = None
314+
):
315+
super().__init__(
316+
environment=base_url
317+
and ElevenLabsEnvironment(
318+
base=f"https://{get_base_url_host(base_url)}",
319+
wss=f"wss://{get_base_url_host(base_url)}",
320+
)
321+
or environment,
322+
api_key=api_key,
323+
timeout=timeout,
324+
httpx_client=httpx_client
325+
)
326+
self.webhooks = WebhooksClient()
327+
306328
@deprecated_async
307329
async def clone(
308330
self,

src/elevenlabs/webhooks.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import json
2+
import hmac
3+
import hashlib
4+
import time
5+
from typing import Any, Dict
6+
from .errors import BadRequestError
7+
8+
9+
class WebhooksClient:
10+
"""
11+
A client to handle ElevenLabs webhook-related functionality
12+
"""
13+
14+
def construct_event(self, rawBody: str, sig_header: str, secret: str) -> Dict:
15+
"""
16+
Constructs a webhook event object from a payload and signature.
17+
Verifies the webhook signature to ensure the event came from ElevenLabs.
18+
19+
Args:
20+
rawBody: The webhook request body. Must be the raw body, not a JSON object
21+
sig_header: The signature header from the request
22+
secret: Your webhook secret
23+
24+
Returns:
25+
The verified webhook event
26+
27+
Raises:
28+
BadRequestError: If the signature is invalid or missing
29+
"""
30+
31+
if not sig_header:
32+
raise BadRequestError(body="Missing signature header")
33+
34+
if not secret:
35+
raise BadRequestError(body="Webhook secret not configured")
36+
37+
headers = sig_header.split(',')
38+
timestamp = None
39+
signature = None
40+
41+
for header in headers:
42+
if header.startswith('t='):
43+
timestamp = header[2:]
44+
elif header.startswith('v0='):
45+
signature = header
46+
47+
if not timestamp or not signature:
48+
raise BadRequestError(body="No signature hash found with expected scheme v0")
49+
50+
# Validate timestamp
51+
req_timestamp = int(timestamp) * 1000
52+
tolerance = int(time.time() * 1000) - 30 * 60 * 1000
53+
if req_timestamp < tolerance:
54+
raise BadRequestError(body="Timestamp outside the tolerance zone")
55+
56+
# Validate hash
57+
message = f"{timestamp}.{rawBody}"
58+
59+
digest = "v0=" + hmac.new(
60+
secret.encode('utf-8'),
61+
message.encode('utf-8'),
62+
hashlib.sha256
63+
).hexdigest()
64+
65+
if signature != digest:
66+
raise BadRequestError(
67+
body="Signature hash does not match the expected signature hash for payload"
68+
)
69+
70+
return json.loads(rawBody)

tests/test_webhooks.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import json
2+
import time
3+
import hmac
4+
import hashlib
5+
from unittest import mock
6+
7+
from elevenlabs.client import ElevenLabs
8+
from elevenlabs.errors import BadRequestError
9+
10+
import pytest
11+
12+
13+
def test_construct_event_valid_signature():
14+
"""Test webhook event construction with valid signature."""
15+
# Setup
16+
client = ElevenLabs()
17+
webhook_secret = "test_secret"
18+
payload = {"event_type": "speech.completed", "id": "123456"}
19+
20+
# Create a valid signature
21+
body = json.dumps(payload)
22+
timestamp = str(int(time.time()))
23+
message = f"{timestamp}.{body}"
24+
signature = "v0=" + hmac.new(
25+
webhook_secret.encode('utf-8'),
26+
message.encode('utf-8'),
27+
hashlib.sha256
28+
).hexdigest()
29+
sig_header = f"t={timestamp},{signature}"
30+
31+
# Verify event construction
32+
event = client.webhooks.construct_event(body, sig_header, webhook_secret)
33+
assert event == payload, "Event should match the original payload"
34+
35+
36+
def test_construct_event_missing_signature():
37+
"""Test webhook event construction with missing signature header."""
38+
client = ElevenLabs()
39+
webhook_secret = "test_secret"
40+
payload = {"event_type": "speech.completed", "id": "123456"}
41+
42+
with pytest.raises(BadRequestError) as excinfo:
43+
client.webhooks.construct_event(payload, "", webhook_secret)
44+
45+
assert "Missing signature header" in str(excinfo.value)
46+
47+
48+
def test_construct_event_invalid_signature_format():
49+
"""Test webhook event construction with invalid signature format."""
50+
client = ElevenLabs()
51+
webhook_secret = "test_secret"
52+
payload = {"event_type": "speech.completed", "id": "123456"}
53+
body = json.dumps(payload)
54+
sig_header = "invalid_format"
55+
56+
with pytest.raises(BadRequestError) as excinfo:
57+
client.webhooks.construct_event(body, sig_header, webhook_secret)
58+
59+
assert "No signature hash found with expected scheme v0" in str(excinfo.value)
60+
61+
62+
def test_construct_event_expired_timestamp():
63+
"""Test webhook event construction with expired timestamp."""
64+
client = ElevenLabs()
65+
webhook_secret = "test_secret"
66+
payload = {"event_type": "speech.completed", "id": "123456"}
67+
68+
# Create an expired timestamp (31 minutes old)
69+
expired_time = int(time.time()) - 31 * 60
70+
timestamp = str(expired_time)
71+
72+
body = json.dumps(payload)
73+
message = f"{timestamp}.{body}"
74+
signature = "v0=" + hmac.new(
75+
webhook_secret.encode('utf-8'),
76+
message.encode('utf-8'),
77+
hashlib.sha256
78+
).hexdigest()
79+
sig_header = f"t={timestamp},{signature}"
80+
81+
with pytest.raises(BadRequestError) as excinfo:
82+
client.webhooks.construct_event(body, sig_header, webhook_secret)
83+
84+
assert "Timestamp outside the tolerance zone" in str(excinfo.value)
85+
86+
87+
def test_construct_event_invalid_signature():
88+
"""Test webhook event construction with invalid signature."""
89+
client = ElevenLabs()
90+
webhook_secret = "test_secret"
91+
payload = {"event_type": "speech.completed", "id": "123456"}
92+
body = json.dumps(payload)
93+
94+
timestamp = str(int(time.time()))
95+
sig_header = f"t={timestamp},v0=invalid_signature"
96+
97+
with pytest.raises(BadRequestError) as excinfo:
98+
client.webhooks.construct_event(body, sig_header, webhook_secret)
99+
100+
assert "Signature hash does not match" in str(excinfo.value)
101+
102+
103+
def test_construct_event_missing_secret():
104+
"""Test webhook event construction with missing secret."""
105+
client = ElevenLabs()
106+
payload = {"event_type": "speech.completed", "id": "123456"}
107+
body = json.dumps(payload)
108+
109+
timestamp = str(int(time.time()))
110+
sig_header = f"t={timestamp},v0=some_signature"
111+
112+
with pytest.raises(BadRequestError) as excinfo:
113+
client.webhooks.construct_event(body, sig_header, "")
114+
115+
assert "Webhook secret not configured" in str(excinfo.value)
116+
117+
118+
@mock.patch('time.time')
119+
def test_construct_event_mocked_time(mock_time):
120+
"""Test webhook event construction with mocked time."""
121+
mock_time.return_value = 1600000000
122+
123+
client = ElevenLabs()
124+
webhook_secret = "test_secret"
125+
payload = {"event_type": "speech.completed", "id": "123456"}
126+
127+
# Create a valid signature with fixed timestamp
128+
body = json.dumps(payload)
129+
timestamp = "1600000000"
130+
message = f"{timestamp}.{body}"
131+
signature = "v0=" + hmac.new(
132+
webhook_secret.encode('utf-8'),
133+
message.encode('utf-8'),
134+
hashlib.sha256
135+
).hexdigest()
136+
sig_header = f"t={timestamp},{signature}"
137+
138+
# Verify event construction
139+
event = client.webhooks.construct_event(body, sig_header, webhook_secret)
140+
assert event == payload, "Event should match the original payload"

0 commit comments

Comments
 (0)