From 655a6600972aee2cefcf83c73fdfd9e1ae68b852 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 03:32:27 +0000 Subject: [PATCH 01/10] chore: update github action --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf09c2f..ccb20d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: run: ./scripts/lint build: - if: github.repository == 'stainless-sdks/browser-use-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork timeout-minutes: 10 name: build permissions: @@ -61,12 +61,14 @@ jobs: run: rye build - name: Get GitHub OIDC Token + if: github.repository == 'stainless-sdks/browser-use-python' id: github-oidc uses: actions/github-script@v6 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball + if: github.repository == 'stainless-sdks/browser-use-python' env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} From 6f8330d8d2d18bc3ad5e5cd013b8d01f7205d87b Mon Sep 17 00:00:00 2001 From: Matic Zavadlal Date: Fri, 22 Aug 2025 13:16:27 +0100 Subject: [PATCH 02/10] stash --- README.md | 245 ++++++++++++---------------- examples/webhooks.py | 60 +++++++ src/browser_use_sdk/lib/webhooks.py | 132 +++++++++++++++ tests/test_webhooks.py | 165 +++++++++++++++++++ 4 files changed, 465 insertions(+), 137 deletions(-) create mode 100755 examples/webhooks.py create mode 100644 src/browser_use_sdk/lib/webhooks.py create mode 100644 tests/test_webhooks.py diff --git a/README.md b/README.md index e313e6a..72eab33 100644 --- a/README.md +++ b/README.md @@ -26,65 +26,6 @@ result.done_output > The full API of this library can be found in [api.md](api.md). -## Async usage - -Simply import `AsyncBrowserUse` instead of `BrowserUse` and use `await` with each API call: - -```python -import os -import asyncio -from browser_use_sdk import AsyncBrowserUse - -client = AsyncBrowserUse( - api_key=os.environ.get("BROWSER_USE_API_KEY"), # This is the default and can be omitted -) - - -async def main() -> None: - task = await client.tasks.run( - task="Search for the top 10 Hacker News posts and return the title and url.", - ) - print(task.done_output) - - -asyncio.run(main()) -``` - -Functionality between the synchronous and asynchronous clients is otherwise identical. - -### With aiohttp - -By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. - -You can enable this by installing `aiohttp`: - -```sh -# install from PyPI -pip install browser-use-sdk[aiohttp] -``` - -Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: - -```python -import asyncio -from browser_use_sdk import DefaultAioHttpClient -from browser_use_sdk import AsyncBrowserUse - - -async def main() -> None: - async with AsyncBrowserUse( - api_key="My API Key", - http_client=DefaultAioHttpClient(), - ) as client: - task = await client.tasks.run( - task="Search for the top 10 Hacker News posts and return the title and url.", - ) - print(task.done_output) - - -asyncio.run(main()) -``` - ## Structured Output with Pydantic Browser Use Python SDK provides first class support for Pydantic models. @@ -115,6 +56,10 @@ asyncio.run(main()) ## Streaming Updates with Async Iterators +> When presenting a long running task you might want to show updates as they happen. + +Browser Use SDK exposes a `.stream` method that lets you subscribe to a sync or an async generator that automatically polls Browser Use Cloud servers and emits a new event when an update happens (e.g., live url becomes available, agent takes a new step, or agent completes the task). + ```py class HackerNewsPost(BaseModel): title: str @@ -132,7 +77,7 @@ async def main() -> None: structured_output_json=SearchResult, ) - async for update in client.tasks.stream(structured_task.id, structured_output_json=SearchResult): + async for update in client.tasks.stream(task.id, structured_output_json=SearchResult): if len(update.steps) > 0: last_step = update.steps[-1] print(f"{update.status}: {last_step.url} ({last_step.next_goal})") @@ -152,6 +97,109 @@ async def main() -> None: asyncio.run(main()) ``` +## Verifying Webhook Events + +> You can configure Browser Use Cloud to emit Webhook events and process them easily with Browser Use Python SDK. + +Browser Use SDK lets you easily verify the signature and structure of the payload you receive in the webhook. + +```py +import uvicorn +import os +from browser_use_sdk.lib.webhooks import Webhook, verify_webhook_event_signature + +from fastapi import FastAPI, Request, HTTPException + +app = FastAPI() + +SECRET_KEY = os.environ['SECRET_KEY'] + +@app.post('/webhook') +async def webhook(request: Request): + body = await request.json() + + timestamp = request.headers.get('X-Browser-Use-Timestamp') + signature = request.headers.get('X-Browser-Use-Signature') + + verified_webhook: Webhook = verify_webhook_event_signature( + body=body, + signature=signature, + timestamp=timestamp, + secret=SECRET_KEY, + ) + + if verified_webhook is not None: + print('Webhook received:', verified_webhook) + else: + print('Invalid webhook received') + + return {'status': 'success', 'message': 'Webhook received'} + +if __name__ == '__main__': + uvicorn.run(app, host='0.0.0.0', port=8080) +``` + +## Async usage + +Simply import `AsyncBrowserUse` instead of `BrowserUse` and use `await` with each API call: + +```python +import os +import asyncio +from browser_use_sdk import AsyncBrowserUse + +client = AsyncBrowserUse( + api_key=os.environ.get("BROWSER_USE_API_KEY"), # This is the default and can be omitted +) + + +async def main() -> None: + task = await client.tasks.run( + task="Search for the top 10 Hacker News posts and return the title and url.", + ) + print(task.done_output) + + +asyncio.run(main()) +``` + +Functionality between the synchronous and asynchronous clients is otherwise identical. + +### With aiohttp + +By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. + +You can enable this by installing `aiohttp`: + +```sh +# install from PyPI +pip install browser-use-sdk[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import asyncio +from browser_use_sdk import DefaultAioHttpClient +from browser_use_sdk import AsyncBrowserUse + + +async def main() -> None: + async with AsyncBrowserUse( + api_key="My API Key", + http_client=DefaultAioHttpClient(), + ) as client: + task = await client.tasks.run( + task="Search for the top 10 Hacker News posts and return the title and url.", + ) + print(task.done_output) + + +asyncio.run(main()) +``` + +## Advanced + ## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `browser_use_sdk.APIConnectionError` is raised. @@ -247,8 +295,6 @@ On timeout, an `APITimeoutError` is thrown. Note that requests that time out are [retried twice by default](#retries). -## Advanced - ### Logging We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. @@ -294,58 +340,6 @@ These methods return an [`APIResponse`](https://github.com/browser-use/browser-u The async client returns an [`AsyncAPIResponse`](https://github.com/browser-use/browser-use-python/tree/main/src/browser_use_sdk/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. -#### `.with_streaming_response` - -The above interface eagerly reads the full response body when you make the request, which may not always be what you want. - -To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. - -```python -with client.tasks.with_streaming_response.create( - task="Search for the top 10 Hacker News posts and return the title and url.", -) as response: - print(response.headers.get("X-My-Header")) - - for line in response.iter_lines(): - print(line) -``` - -The context manager is required so that the response will reliably be closed. - -### Making custom/undocumented requests - -This library is typed for convenient access to the documented API. - -If you need to access undocumented endpoints, params, or response properties, the library can still be used. - -#### Undocumented endpoints - -To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other -http verbs. Options on the client will be respected (such as retries) when making this request. - -```py -import httpx - -response = client.post( - "/foo", - cast_to=httpx.Response, - body={"my_param": True}, -) - -print(response.headers.get("x-foo")) -``` - -#### Undocumented request params - -If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request -options. - -#### Undocumented response properties - -To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You -can also get all the extra fields on the Pydantic model as a dict with -[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). - ### Configuring the HTTP client You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: @@ -388,29 +382,6 @@ with BrowserUse() as client: # HTTP client is now closed ``` -## Versioning - -This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: - -1. Changes that only affect static types, without breaking runtime behavior. -2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ -3. Changes that we do not expect to impact the vast majority of users in practice. - -We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. - -We are keen for your feedback; please open an [issue](https://www.github.com/browser-use/browser-use-python/issues) with questions, bugs, or suggestions. - -### Determining the installed version - -If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. - -You can determine the version that is being used at runtime with: - -```py -import browser_use_sdk -print(browser_use_sdk.__version__) -``` - ## Requirements Python 3.8 or higher. diff --git a/examples/webhooks.py b/examples/webhooks.py new file mode 100755 index 0000000..77f0ce0 --- /dev/null +++ b/examples/webhooks.py @@ -0,0 +1,60 @@ +#!/usr/bin/env -S rye run python + +from typing import Any, Dict, Tuple +from datetime import datetime + +from browser_use_sdk.lib.webhooks import ( + Webhook, + WebhookAgentTaskStatusUpdate, + WebhookAgentTaskStatusUpdatePayload, + create_webhook_signature, + verify_webhook_event_signature, +) + +SECRET = "your-webhook-secret-key" + + +def mock_webhook_event() -> Tuple[Dict[str, Any], str, str]: + """Mock a webhook event.""" + + timestamp = datetime.fromisoformat("2023-01-01T00:00:00") + payload = WebhookAgentTaskStatusUpdatePayload( + session_id="sess_123", + task_id="task_123", + status="started", + metadata={"progress": 25}, + ) + signature = create_webhook_signature( + payload=payload.model_dump(), timestamp="2023-01-01T00:00:00", secret="your-webhook-secret-key" + ) + + evt: Webhook = WebhookAgentTaskStatusUpdate( + type="agent.task.status_update", + timestamp=timestamp, + payload=payload, + ) + + return evt.model_dump(), signature, timestamp.isoformat() + + +def main() -> None: + """Demonstrate webhook functionality.""" + + # NOTE: You'd get the evt and signature from the webhook request body and headers! + evt, signature, timestamp = mock_webhook_event() + verified_webhook = verify_webhook_event_signature( + body=evt, + signature=signature, + timestamp=timestamp, + secret=SECRET, + ) + + if verified_webhook is not None: + print("✓ Webhook signature verified successfully") + print(f" Event type: {verified_webhook.type}") + else: + print("✗ Webhook signature verification failed") + + +if __name__ == "__main__": + main() diff --git a/src/browser_use_sdk/lib/webhooks.py b/src/browser_use_sdk/lib/webhooks.py new file mode 100644 index 0000000..5c063ed --- /dev/null +++ b/src/browser_use_sdk/lib/webhooks.py @@ -0,0 +1,132 @@ +import hmac +import json +import hashlib +from typing import Any, Dict, Union, Literal, Optional +from datetime import datetime + +from pydantic import BaseModel + +# https://docs.browser-use.com/cloud/webhooks + +# Models --------------------------------------------------------------------- + +# test + + +class WebhookTestPayload(BaseModel): + """Test webhook payload.""" + + test: Literal["ok"] + + +class WebhookTest(BaseModel): + """Test webhook event.""" + + type: Literal["test"] + timestamp: datetime + payload: WebhookTestPayload + + +# agent.task.status_update + + +class WebhookAgentTaskStatusUpdatePayload(BaseModel): + """Agent task status update webhook payload.""" + + session_id: str + task_id: str + status: Literal["initializing", "started", "paused", "stopped", "finished"] + metadata: Optional[Dict[str, Any]] = None + + +class WebhookAgentTaskStatusUpdate(BaseModel): + """Agent task status update webhook event.""" + + type: Literal["agent.task.status_update"] + timestamp: datetime + payload: WebhookAgentTaskStatusUpdatePayload + + +# Union of all webhook types +Webhook = Union[WebhookTest, WebhookAgentTaskStatusUpdate] + +# Methods -------------------------------------------------------------------- + + +def create_webhook_signature(payload: Any, timestamp: str, secret: str) -> str: + """ + Creates a webhook signature for the given payload, timestamp, and secret. + + Args: + payload: The webhook payload to sign + timestamp: The timestamp string + secret: The secret key for signing + + Returns: + The HMAC-SHA256 signature as a hex string + """ + # Sort keys and use compact JSON format (equivalent to fast-json-stable-stringify) + dump = json.dumps(payload, separators=(",", ":"), sort_keys=True) + message = f"{timestamp}.{dump}" + + # Create HMAC-SHA256 signature + hmac_obj = hmac.new(secret.encode(), message.encode(), hashlib.sha256) + return hmac_obj.hexdigest() + + +def verify_webhook_event_signature( + body: Union[Dict[str, Any], str], + signature: str, + timestamp: str, + # + secret: str, +) -> Union["Webhook", None]: + """ + Utility function that validates the received webhook event signature. + + Args: + body: Dictionary containing 'body', 'signature', and 'timestamp' + signature: The signature of the webhook event + timestamp: The timestamp of the webhook event + secret: The secret key for signing + + Returns: + None if the signature is invalid, otherwise the parsed webhook event. + """ + try: + # Parse body if it's a string + if isinstance(body, str): + json_data = json.loads(body) + else: + json_data = body + + # Try to parse as each webhook type + webhook_event: Optional[Webhook] = None + + # Try test webhook first + try: + webhook_event = WebhookTest(**json_data) + except Exception: + pass + + # Try agent task status update webhook + if webhook_event is None: + try: + webhook_event = WebhookAgentTaskStatusUpdate(**json_data) + except Exception: + pass + + if webhook_event is None: + return None + + # Create expected signature + expected_signature = create_webhook_signature(payload=webhook_event.payload, timestamp=timestamp, secret=secret) + + # Compare signatures using timing-safe comparison + if not hmac.compare_digest(signature, expected_signature): + return None + + return webhook_event + + except Exception: + return None diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..1c32d81 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,165 @@ +"""Tests for webhook functionality.""" + +from __future__ import annotations + +from typing import Any, Dict +from datetime import datetime, timezone + +from browser_use_sdk.lib.webhooks import ( + WebhookTest, + WebhookTestPayload, + WebhookAgentTaskStatusUpdate, + WebhookAgentTaskStatusUpdatePayload, + create_webhook_signature, + verify_webhook_event_signature, +) + + +def test_create_webhook_signature() -> None: + """Test webhook signature creation.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + payload = {"test": "ok"} + + signature = create_webhook_signature(payload, timestamp, secret) + + # Signature should be a hex string + assert isinstance(signature, str) + assert len(signature) == 64 # SHA256 hex length + + # Same inputs should produce same signature + signature2 = create_webhook_signature(payload, timestamp, secret) + assert signature == signature2 + + # Different payload should produce different signature + different_payload = {"test": "different"} + different_signature = create_webhook_signature(different_payload, timestamp, secret) + assert signature != different_signature + + +def test_verify_webhook_event_signature_valid() -> None: + """Test webhook signature verification with valid signature.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + + # Create test webhook + payload = WebhookTestPayload(test="ok") + webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) + + # Create signature + signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) + + # Verify signature + verified_webhook = verify_webhook_event_signature( + body=webhook.model_dump(), secret=secret, signature=signature, timestamp=timestamp + ) + + assert verified_webhook is not None + assert isinstance(verified_webhook, WebhookTest) + assert verified_webhook.payload.test == "ok" + + +def test_verify_webhook_event_signature_invalid_signature() -> None: + """Test webhook signature verification with invalid signature.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + + # Create test webhook + payload = WebhookTestPayload(test="ok") + webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) + + # Use invalid signature + invalid_signature = "invalid_signature_123" + + verified_webhook = verify_webhook_event_signature( + body=webhook.model_dump(), secret=secret, signature=invalid_signature, timestamp=timestamp + ) + + assert verified_webhook is None + + +def test_verify_webhook_event_signature_wrong_secret() -> None: + """Test webhook signature verification with wrong secret.""" + secret = "test-secret-key" + wrong_secret = "wrong-secret-key" + timestamp = "2023-01-01T00:00:00Z" + + # Create test webhook + payload = WebhookTestPayload(test="ok") + webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) + + # Create signature with correct secret + signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) + + # Verify with wrong secret + verified_webhook = verify_webhook_event_signature( + body=webhook.model_dump(), secret=wrong_secret, signature=signature, timestamp=timestamp + ) + + assert verified_webhook is None + + +def test_verify_webhook_event_signature_agent_task_status_update() -> None: + """Test webhook signature verification for agent task status update.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + + # Create agent task status update webhook + payload = WebhookAgentTaskStatusUpdatePayload( + session_id="sess_123", task_id="task_456", status="started", metadata={"progress": 25} + ) + + webhook = WebhookAgentTaskStatusUpdate( + type="agent.task.status_update", timestamp=datetime.now(timezone.utc), payload=payload + ) + + # Create signature + signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) + + # Verify signature + verified_webhook = verify_webhook_event_signature( + body=webhook.model_dump(), secret=secret, signature=signature, timestamp=timestamp + ) + + assert verified_webhook is not None + assert isinstance(verified_webhook, WebhookAgentTaskStatusUpdate) + assert verified_webhook.payload.session_id == "sess_123" + assert verified_webhook.payload.status == "started" + assert verified_webhook.payload.metadata is not None + assert verified_webhook.payload.metadata["progress"] == 25 + + +def test_verify_webhook_event_signature_string_body() -> None: + """Test webhook signature verification with string body.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + + # Create test webhook + payload = WebhookTestPayload(test="ok") + webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) + + # Create signature + signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) + + # Verify with string body + verified_webhook = verify_webhook_event_signature( + body=webhook.model_dump(), secret=secret, signature=signature, timestamp=timestamp + ) + + assert verified_webhook is not None + assert isinstance(verified_webhook, WebhookTest) + + +def test_verify_webhook_event_signature_invalid_body() -> None: + """Test webhook signature verification with invalid body.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + + # Invalid webhook data + invalid_body: Dict[str, Any] = {"type": "invalid_type", "timestamp": "invalid", "payload": {}} + + verified_webhook = verify_webhook_event_signature( + body=invalid_body, secret=secret, signature="some_signature", timestamp=timestamp + ) + + assert verified_webhook is None From b46ac62179b06cbf005c012aea93e85579298a96 Mon Sep 17 00:00:00 2001 From: Matic Zavadlal Date: Fri, 22 Aug 2025 13:32:16 +0100 Subject: [PATCH 03/10] fixes --- examples/webhooks.py | 19 ++++++++++++------- src/browser_use_sdk/lib/webhooks.py | 28 ++++++++++++++++------------ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/examples/webhooks.py b/examples/webhooks.py index 77f0ce0..7c0a020 100755 --- a/examples/webhooks.py +++ b/examples/webhooks.py @@ -17,24 +17,28 @@ def mock_webhook_event() -> Tuple[Dict[str, Any], str, str]: """Mock a webhook event.""" - timestamp = datetime.fromisoformat("2023-01-01T00:00:00") + timestamp = datetime.fromisoformat("2023-01-01T00:00:00").isoformat() + payload = WebhookAgentTaskStatusUpdatePayload( session_id="sess_123", task_id="task_123", status="started", metadata={"progress": 25}, ) + signature = create_webhook_signature( - payload=payload.model_dump(), timestamp="2023-01-01T00:00:00", secret="your-webhook-secret-key" + payload=payload.model_dump(), + timestamp=timestamp, + secret=SECRET, ) evt: Webhook = WebhookAgentTaskStatusUpdate( type="agent.task.status_update", - timestamp=timestamp, + timestamp=datetime.fromisoformat("2023-01-01T00:00:00"), payload=payload, ) - return evt.model_dump(), signature, timestamp.isoformat() + return evt.model_dump(), signature, timestamp def main() -> None: @@ -42,6 +46,7 @@ def main() -> None: # NOTE: You'd get the evt and signature from the webhook request body and headers! evt, signature, timestamp = mock_webhook_event() + verified_webhook = verify_webhook_event_signature( body=evt, signature=signature, @@ -49,11 +54,11 @@ def main() -> None: secret=SECRET, ) - if verified_webhook is not None: + if verified_webhook is None: + print("✗ Webhook signature verification failed") + else: print("✓ Webhook signature verified successfully") print(f" Event type: {verified_webhook.type}") - else: - print("✗ Webhook signature verification failed") if __name__ == "__main__": diff --git a/src/browser_use_sdk/lib/webhooks.py b/src/browser_use_sdk/lib/webhooks.py index 5c063ed..0162680 100644 --- a/src/browser_use_sdk/lib/webhooks.py +++ b/src/browser_use_sdk/lib/webhooks.py @@ -65,13 +65,15 @@ def create_webhook_signature(payload: Any, timestamp: str, secret: str) -> str: Returns: The HMAC-SHA256 signature as a hex string """ - # Sort keys and use compact JSON format (equivalent to fast-json-stable-stringify) + dump = json.dumps(payload, separators=(",", ":"), sort_keys=True) message = f"{timestamp}.{dump}" # Create HMAC-SHA256 signature hmac_obj = hmac.new(secret.encode(), message.encode(), hashlib.sha256) - return hmac_obj.hexdigest() + signature = hmac_obj.hexdigest() + + return signature def verify_webhook_event_signature( @@ -94,20 +96,20 @@ def verify_webhook_event_signature( None if the signature is invalid, otherwise the parsed webhook event. """ try: - # Parse body if it's a string if isinstance(body, str): json_data = json.loads(body) else: json_data = body - # Try to parse as each webhook type + # PARSE + webhook_event: Optional[Webhook] = None - # Try test webhook first - try: - webhook_event = WebhookTest(**json_data) - except Exception: - pass + if webhook_event is None: + try: + webhook_event = WebhookTest(**json_data) + except Exception: + pass # Try agent task status update webhook if webhook_event is None: @@ -119,10 +121,12 @@ def verify_webhook_event_signature( if webhook_event is None: return None - # Create expected signature - expected_signature = create_webhook_signature(payload=webhook_event.payload, timestamp=timestamp, secret=secret) + # Verify + + expected_signature = create_webhook_signature( + payload=webhook_event.payload.model_dump(), timestamp=timestamp, secret=secret + ) - # Compare signatures using timing-safe comparison if not hmac.compare_digest(signature, expected_signature): return None From 780bcdd8ca124c344bc27c6d5111e742fe83f24f Mon Sep 17 00:00:00 2001 From: Matic Zavadlal Date: Fri, 22 Aug 2025 13:37:00 +0100 Subject: [PATCH 04/10] fixes --- README.md | 2 +- examples/webhooks.py | 2 +- src/browser_use_sdk/lib/webhooks.py | 4 +- tests/test_webhooks.py | 69 +++++++++-------------------- 4 files changed, 26 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 72eab33..b305f11 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,9 @@ async def webhook(request: Request): verified_webhook: Webhook = verify_webhook_event_signature( body=body, - signature=signature, timestamp=timestamp, secret=SECRET_KEY, + expected_signature=signature, ) if verified_webhook is not None: diff --git a/examples/webhooks.py b/examples/webhooks.py index 7c0a020..65cb93b 100755 --- a/examples/webhooks.py +++ b/examples/webhooks.py @@ -49,7 +49,7 @@ def main() -> None: verified_webhook = verify_webhook_event_signature( body=evt, - signature=signature, + expected_signature=signature, timestamp=timestamp, secret=SECRET, ) diff --git a/src/browser_use_sdk/lib/webhooks.py b/src/browser_use_sdk/lib/webhooks.py index 0162680..72fe83b 100644 --- a/src/browser_use_sdk/lib/webhooks.py +++ b/src/browser_use_sdk/lib/webhooks.py @@ -78,7 +78,7 @@ def create_webhook_signature(payload: Any, timestamp: str, secret: str) -> str: def verify_webhook_event_signature( body: Union[Dict[str, Any], str], - signature: str, + expected_signature: str, timestamp: str, # secret: str, @@ -127,7 +127,7 @@ def verify_webhook_event_signature( payload=webhook_event.payload.model_dump(), timestamp=timestamp, secret=secret ) - if not hmac.compare_digest(signature, expected_signature): + if not hmac.compare_digest(expected_signature, expected_signature): return None return webhook_event diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 1c32d81..83a8ddd 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -8,8 +8,6 @@ from browser_use_sdk.lib.webhooks import ( WebhookTest, WebhookTestPayload, - WebhookAgentTaskStatusUpdate, - WebhookAgentTaskStatusUpdatePayload, create_webhook_signature, verify_webhook_event_signature, ) @@ -23,17 +21,15 @@ def test_create_webhook_signature() -> None: signature = create_webhook_signature(payload, timestamp, secret) - # Signature should be a hex string assert isinstance(signature, str) - assert len(signature) == 64 # SHA256 hex length + assert len(signature) == 64 - # Same inputs should produce same signature signature2 = create_webhook_signature(payload, timestamp, secret) assert signature == signature2 - # Different payload should produce different signature different_payload = {"test": "different"} different_signature = create_webhook_signature(different_payload, timestamp, secret) + assert signature != different_signature @@ -51,7 +47,10 @@ def test_verify_webhook_event_signature_valid() -> None: # Verify signature verified_webhook = verify_webhook_event_signature( - body=webhook.model_dump(), secret=secret, signature=signature, timestamp=timestamp + body=webhook.model_dump(), + secret=secret, + timestamp=timestamp, + expected_signature=signature, ) assert verified_webhook is not None @@ -68,11 +67,11 @@ def test_verify_webhook_event_signature_invalid_signature() -> None: payload = WebhookTestPayload(test="ok") webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) - # Use invalid signature - invalid_signature = "invalid_signature_123" - verified_webhook = verify_webhook_event_signature( - body=webhook.model_dump(), secret=secret, signature=invalid_signature, timestamp=timestamp + body=webhook.model_dump(), + secret=secret, + timestamp=timestamp, + expected_signature="random_invalid_signature", ) assert verified_webhook is None @@ -80,8 +79,7 @@ def test_verify_webhook_event_signature_invalid_signature() -> None: def test_verify_webhook_event_signature_wrong_secret() -> None: """Test webhook signature verification with wrong secret.""" - secret = "test-secret-key" - wrong_secret = "wrong-secret-key" + timestamp = "2023-01-01T00:00:00Z" # Create test webhook @@ -89,46 +87,23 @@ def test_verify_webhook_event_signature_wrong_secret() -> None: webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) # Create signature with correct secret - signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) + signature = create_webhook_signature( + payload=webhook.payload.model_dump(), + timestamp=timestamp, + secret="test-secret-key", + ) # Verify with wrong secret verified_webhook = verify_webhook_event_signature( - body=webhook.model_dump(), secret=wrong_secret, signature=signature, timestamp=timestamp + body=webhook.model_dump(), + secret="wrong-secret-key", + timestamp=timestamp, + expected_signature=signature, ) assert verified_webhook is None -def test_verify_webhook_event_signature_agent_task_status_update() -> None: - """Test webhook signature verification for agent task status update.""" - secret = "test-secret-key" - timestamp = "2023-01-01T00:00:00Z" - - # Create agent task status update webhook - payload = WebhookAgentTaskStatusUpdatePayload( - session_id="sess_123", task_id="task_456", status="started", metadata={"progress": 25} - ) - - webhook = WebhookAgentTaskStatusUpdate( - type="agent.task.status_update", timestamp=datetime.now(timezone.utc), payload=payload - ) - - # Create signature - signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) - - # Verify signature - verified_webhook = verify_webhook_event_signature( - body=webhook.model_dump(), secret=secret, signature=signature, timestamp=timestamp - ) - - assert verified_webhook is not None - assert isinstance(verified_webhook, WebhookAgentTaskStatusUpdate) - assert verified_webhook.payload.session_id == "sess_123" - assert verified_webhook.payload.status == "started" - assert verified_webhook.payload.metadata is not None - assert verified_webhook.payload.metadata["progress"] == 25 - - def test_verify_webhook_event_signature_string_body() -> None: """Test webhook signature verification with string body.""" secret = "test-secret-key" @@ -143,7 +118,7 @@ def test_verify_webhook_event_signature_string_body() -> None: # Verify with string body verified_webhook = verify_webhook_event_signature( - body=webhook.model_dump(), secret=secret, signature=signature, timestamp=timestamp + body=webhook.model_dump(), secret=secret, expected_signature=signature, timestamp=timestamp ) assert verified_webhook is not None @@ -159,7 +134,7 @@ def test_verify_webhook_event_signature_invalid_body() -> None: invalid_body: Dict[str, Any] = {"type": "invalid_type", "timestamp": "invalid", "payload": {}} verified_webhook = verify_webhook_event_signature( - body=invalid_body, secret=secret, signature="some_signature", timestamp=timestamp + body=invalid_body, secret=secret, expected_signature="some_signature", timestamp=timestamp ) assert verified_webhook is None From 2a5297de1270368726ec378b22ea1b2561bea368 Mon Sep 17 00:00:00 2001 From: Matic Zavadlal Date: Fri, 22 Aug 2025 13:40:44 +0100 Subject: [PATCH 05/10] Update test_webhooks.py --- tests/test_webhooks.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 83a8ddd..8af16bf 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import Any, Dict from datetime import datetime, timezone @@ -12,6 +13,8 @@ verify_webhook_event_signature, ) +# Signature Creation --------------------------------------------------------- + def test_create_webhook_signature() -> None: """Test webhook signature creation.""" @@ -33,16 +36,16 @@ def test_create_webhook_signature() -> None: assert signature != different_signature +# Webhook Verification -------------------------------------------------------- + + def test_verify_webhook_event_signature_valid() -> None: """Test webhook signature verification with valid signature.""" secret = "test-secret-key" timestamp = "2023-01-01T00:00:00Z" - # Create test webhook payload = WebhookTestPayload(test="ok") webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) - - # Create signature signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) # Verify signature @@ -88,7 +91,7 @@ def test_verify_webhook_event_signature_wrong_secret() -> None: # Create signature with correct secret signature = create_webhook_signature( - payload=webhook.payload.model_dump(), + payload=payload.model_dump(), timestamp=timestamp, secret="test-secret-key", ) @@ -112,13 +115,13 @@ def test_verify_webhook_event_signature_string_body() -> None: # Create test webhook payload = WebhookTestPayload(test="ok") webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) - - # Create signature signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) - # Verify with string body verified_webhook = verify_webhook_event_signature( - body=webhook.model_dump(), secret=secret, expected_signature=signature, timestamp=timestamp + body=json.dumps(webhook.model_dump()), + secret=secret, + timestamp=timestamp, + expected_signature=signature, ) assert verified_webhook is not None From 36d7dfc382182cfcb8213e7c18e6f284aaec38f2 Mon Sep 17 00:00:00 2001 From: Matic Zavadlal Date: Fri, 22 Aug 2025 13:42:24 +0100 Subject: [PATCH 06/10] fixes --- src/browser_use_sdk/lib/webhooks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser_use_sdk/lib/webhooks.py b/src/browser_use_sdk/lib/webhooks.py index 72fe83b..1a8f1d2 100644 --- a/src/browser_use_sdk/lib/webhooks.py +++ b/src/browser_use_sdk/lib/webhooks.py @@ -123,11 +123,11 @@ def verify_webhook_event_signature( # Verify - expected_signature = create_webhook_signature( + calculated_signature = create_webhook_signature( payload=webhook_event.payload.model_dump(), timestamp=timestamp, secret=secret ) - if not hmac.compare_digest(expected_signature, expected_signature): + if not hmac.compare_digest(expected_signature, calculated_signature): return None return webhook_event From 9d72a36b9f511d8502e7a129b1be13380e12a39d Mon Sep 17 00:00:00 2001 From: Matic Zavadlal Date: Fri, 22 Aug 2025 13:47:32 +0100 Subject: [PATCH 07/10] Update test_webhooks.py --- tests/test_webhooks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 8af16bf..f26e4cd 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from typing import Any, Dict from datetime import datetime, timezone @@ -118,7 +117,7 @@ def test_verify_webhook_event_signature_string_body() -> None: signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) verified_webhook = verify_webhook_event_signature( - body=json.dumps(webhook.model_dump()), + body=webhook.model_dump_json(), secret=secret, timestamp=timestamp, expected_signature=signature, From 7ddcd16c319624c2defa16cf3d606eaf78a774eb Mon Sep 17 00:00:00 2001 From: Matic Zavadlal Date: Fri, 22 Aug 2025 13:50:32 +0100 Subject: [PATCH 08/10] Update test_webhooks.py --- tests/test_webhooks.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index f26e4cd..84f09d7 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -5,6 +5,9 @@ from typing import Any, Dict from datetime import datetime, timezone +import pytest + +from browser_use_sdk._compat import PYDANTIC_V2 from browser_use_sdk.lib.webhooks import ( WebhookTest, WebhookTestPayload, @@ -15,6 +18,7 @@ # Signature Creation --------------------------------------------------------- +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") def test_create_webhook_signature() -> None: """Test webhook signature creation.""" secret = "test-secret-key" @@ -38,6 +42,7 @@ def test_create_webhook_signature() -> None: # Webhook Verification -------------------------------------------------------- +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") def test_verify_webhook_event_signature_valid() -> None: """Test webhook signature verification with valid signature.""" secret = "test-secret-key" @@ -60,6 +65,7 @@ def test_verify_webhook_event_signature_valid() -> None: assert verified_webhook.payload.test == "ok" +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") def test_verify_webhook_event_signature_invalid_signature() -> None: """Test webhook signature verification with invalid signature.""" secret = "test-secret-key" @@ -79,6 +85,7 @@ def test_verify_webhook_event_signature_invalid_signature() -> None: assert verified_webhook is None +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") def test_verify_webhook_event_signature_wrong_secret() -> None: """Test webhook signature verification with wrong secret.""" @@ -106,6 +113,7 @@ def test_verify_webhook_event_signature_wrong_secret() -> None: assert verified_webhook is None +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") def test_verify_webhook_event_signature_string_body() -> None: """Test webhook signature verification with string body.""" secret = "test-secret-key" @@ -127,6 +135,7 @@ def test_verify_webhook_event_signature_string_body() -> None: assert isinstance(verified_webhook, WebhookTest) +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") def test_verify_webhook_event_signature_invalid_body() -> None: """Test webhook signature verification with invalid body.""" secret = "test-secret-key" From 9dde91eee95530d1f684797254561d444d598e8e Mon Sep 17 00:00:00 2001 From: Matic Zavadlal Date: Fri, 22 Aug 2025 13:59:00 +0100 Subject: [PATCH 09/10] fix: PyPi Image URL --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b305f11..79ee251 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Browser Use Python +Browser Use Python [![PyPI version]()](https://pypi.org/project/browser-use-sdk/) From a35c419d2634770629a734e8c943e293e2ac24f7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:59:16 +0000 Subject: [PATCH 10/10] release: 1.0.2 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/browser_use_sdk/_version.py | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a8f7122..06d6df2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.1" + ".": "1.0.2" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b0a227..284fac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.2 (2025-08-22) + +Full Changelog: [v1.0.1...v1.0.2](https://github.com/browser-use/browser-use-python/compare/v1.0.1...v1.0.2) + +### Chores + +* update github action ([655a660](https://github.com/browser-use/browser-use-python/commit/655a6600972aee2cefcf83c73fdfd9e1ae68b852)) + ## 1.0.1 (2025-08-21) Full Changelog: [v1.0.0...v1.0.1](https://github.com/browser-use/browser-use-python/compare/v1.0.0...v1.0.1) diff --git a/pyproject.toml b/pyproject.toml index f335717..cbc8b6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-use-sdk" -version = "1.0.1" +version = "1.0.2" description = "The official Python library for the browser-use API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/browser_use_sdk/_version.py b/src/browser_use_sdk/_version.py index 8b431f6..dbca030 100644 --- a/src/browser_use_sdk/_version.py +++ b/src/browser_use_sdk/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browser_use_sdk" -__version__ = "1.0.1" # x-release-please-version +__version__ = "1.0.2" # x-release-please-version