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 }}
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/README.md b/README.md
index e313e6a..79ee251 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
[![PyPI version]()](https://pypi.org/project/browser-use-sdk/)
@@ -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,
+ timestamp=timestamp,
+ secret=SECRET_KEY,
+ expected_signature=signature,
+ )
+
+ 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..65cb93b
--- /dev/null
+++ b/examples/webhooks.py
@@ -0,0 +1,65 @@
+#!/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").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=timestamp,
+ secret=SECRET,
+ )
+
+ evt: Webhook = WebhookAgentTaskStatusUpdate(
+ type="agent.task.status_update",
+ timestamp=datetime.fromisoformat("2023-01-01T00:00:00"),
+ payload=payload,
+ )
+
+ return evt.model_dump(), signature, timestamp
+
+
+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,
+ expected_signature=signature,
+ timestamp=timestamp,
+ secret=SECRET,
+ )
+
+ if verified_webhook is None:
+ print("✗ Webhook signature verification failed")
+ else:
+ print("✓ Webhook signature verified successfully")
+ print(f" Event type: {verified_webhook.type}")
+
+
+if __name__ == "__main__":
+ main()
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
diff --git a/src/browser_use_sdk/lib/webhooks.py b/src/browser_use_sdk/lib/webhooks.py
new file mode 100644
index 0000000..1a8f1d2
--- /dev/null
+++ b/src/browser_use_sdk/lib/webhooks.py
@@ -0,0 +1,136 @@
+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
+ """
+
+ 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)
+ signature = hmac_obj.hexdigest()
+
+ return signature
+
+
+def verify_webhook_event_signature(
+ body: Union[Dict[str, Any], str],
+ expected_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:
+ if isinstance(body, str):
+ json_data = json.loads(body)
+ else:
+ json_data = body
+
+ # PARSE
+
+ webhook_event: Optional[Webhook] = None
+
+ 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:
+ try:
+ webhook_event = WebhookAgentTaskStatusUpdate(**json_data)
+ except Exception:
+ pass
+
+ if webhook_event is None:
+ return None
+
+ # Verify
+
+ calculated_signature = create_webhook_signature(
+ payload=webhook_event.payload.model_dump(), timestamp=timestamp, secret=secret
+ )
+
+ if not hmac.compare_digest(expected_signature, calculated_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..84f09d7
--- /dev/null
+++ b/tests/test_webhooks.py
@@ -0,0 +1,151 @@
+"""Tests for webhook functionality."""
+
+from __future__ import annotations
+
+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,
+ create_webhook_signature,
+ verify_webhook_event_signature,
+)
+
+# 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"
+ timestamp = "2023-01-01T00:00:00Z"
+ payload = {"test": "ok"}
+
+ signature = create_webhook_signature(payload, timestamp, secret)
+
+ assert isinstance(signature, str)
+ assert len(signature) == 64
+
+ signature2 = create_webhook_signature(payload, timestamp, secret)
+ assert signature == signature2
+
+ different_payload = {"test": "different"}
+ different_signature = create_webhook_signature(different_payload, timestamp, secret)
+
+ assert signature != different_signature
+
+
+# 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"
+ timestamp = "2023-01-01T00:00:00Z"
+
+ payload = WebhookTestPayload(test="ok")
+ webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload)
+ signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret)
+
+ # Verify signature
+ verified_webhook = verify_webhook_event_signature(
+ body=webhook.model_dump(),
+ secret=secret,
+ timestamp=timestamp,
+ expected_signature=signature,
+ )
+
+ assert verified_webhook is not None
+ assert isinstance(verified_webhook, WebhookTest)
+ 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"
+ timestamp = "2023-01-01T00:00:00Z"
+
+ # Create test webhook
+ payload = WebhookTestPayload(test="ok")
+ webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload)
+
+ verified_webhook = verify_webhook_event_signature(
+ body=webhook.model_dump(),
+ secret=secret,
+ timestamp=timestamp,
+ expected_signature="random_invalid_signature",
+ )
+
+ 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."""
+
+ 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(
+ payload=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-key",
+ timestamp=timestamp,
+ expected_signature=signature,
+ )
+
+ 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"
+ timestamp = "2023-01-01T00:00:00Z"
+
+ # Create test webhook
+ payload = WebhookTestPayload(test="ok")
+ webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload)
+ signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret)
+
+ verified_webhook = verify_webhook_event_signature(
+ body=webhook.model_dump_json(),
+ secret=secret,
+ timestamp=timestamp,
+ expected_signature=signature,
+ )
+
+ assert verified_webhook is not 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"
+ 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, expected_signature="some_signature", timestamp=timestamp
+ )
+
+ assert verified_webhook is None