blueticks 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 serenix-com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: blueticks
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the Blueticks API.
5
+ Author-email: Blueticks <support@blueticks.co>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 serenix-com
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://blueticks.co
29
+ Project-URL: Documentation, https://docs.blueticks.co
30
+ Project-URL: Source, https://github.com/serenix-com/blueticks-python
31
+ Project-URL: Changelog, https://github.com/serenix-com/blueticks-python/blob/main/CHANGELOG.md
32
+ Keywords: blueticks,whatsapp,api,sdk
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Requires-Python: >=3.9
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Requires-Dist: httpx<1.0,>=0.27
46
+ Requires-Dist: pydantic<3.0,>=2.6
47
+ Provides-Extra: dev
48
+ Requires-Dist: pytest>=8.0; extra == "dev"
49
+ Requires-Dist: pytest-httpx>=0.30; extra == "dev"
50
+ Requires-Dist: ruff>=0.5; extra == "dev"
51
+ Requires-Dist: mypy>=1.10; extra == "dev"
52
+ Dynamic: license-file
53
+
54
+ # blueticks — Python client for the Blueticks API
55
+
56
+ Official Python SDK for [Blueticks](https://blueticks.co).
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install blueticks
62
+ ```
63
+
64
+ ## Quickstart
65
+
66
+ ```python
67
+ from blueticks import Blueticks
68
+
69
+ client = Blueticks(api_key="bt_live_...")
70
+ ping = client.ping()
71
+ account = client.account.retrieve()
72
+ print(account.name)
73
+ ```
74
+
75
+ See https://docs.blueticks.co for full documentation.
@@ -0,0 +1,22 @@
1
+ # blueticks — Python client for the Blueticks API
2
+
3
+ Official Python SDK for [Blueticks](https://blueticks.co).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install blueticks
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from blueticks import Blueticks
15
+
16
+ client = Blueticks(api_key="bt_live_...")
17
+ ping = client.ping()
18
+ account = client.account.retrieve()
19
+ print(account.name)
20
+ ```
21
+
22
+ See https://docs.blueticks.co for full documentation.
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "blueticks"
7
+ dynamic = ["version"]
8
+ description = "Official Python client for the Blueticks API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Blueticks", email = "support@blueticks.co" }]
13
+ keywords = ["blueticks", "whatsapp", "api", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.27,<1.0",
27
+ "pydantic>=2.6,<3.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=8.0",
33
+ "pytest-httpx>=0.30",
34
+ "ruff>=0.5",
35
+ "mypy>=1.10",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://blueticks.co"
40
+ Documentation = "https://docs.blueticks.co"
41
+ Source = "https://github.com/serenix-com/blueticks-python"
42
+ Changelog = "https://github.com/serenix-com/blueticks-python/blob/main/CHANGELOG.md"
43
+
44
+ [tool.setuptools.dynamic]
45
+ version = { attr = "blueticks._version.__version__" }
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
49
+
50
+ [tool.setuptools.package-data]
51
+ blueticks = ["py.typed"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py39"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["A", "B", "E", "F", "I", "UP", "W"]
59
+
60
+ [tool.mypy]
61
+ python_version = "3.9"
62
+ strict = true
63
+ warn_return_any = true
64
+ warn_unused_ignores = true
65
+ files = ["src/blueticks"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ from blueticks._client import Blueticks
2
+ from blueticks._errors import (
3
+ APIConnectionError,
4
+ APIError,
5
+ AuthenticationError,
6
+ BadRequestError,
7
+ BluetickError,
8
+ NotFoundError,
9
+ PermissionDeniedError,
10
+ RateLimitError,
11
+ )
12
+ from blueticks._version import __version__
13
+
14
+ __all__ = [
15
+ "APIConnectionError",
16
+ "APIError",
17
+ "AuthenticationError",
18
+ "BadRequestError",
19
+ "Blueticks",
20
+ "BluetickError",
21
+ "NotFoundError",
22
+ "PermissionDeniedError",
23
+ "RateLimitError",
24
+ "__version__",
25
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from blueticks._client import Blueticks
7
+
8
+
9
+ class BaseResource:
10
+ """Shared plumbing for all resource classes. Holds a reference to the parent client."""
11
+
12
+ def __init__(self, client: Blueticks) -> None:
13
+ self._client = client
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ import httpx
7
+
8
+ if TYPE_CHECKING:
9
+ from blueticks.types.ping import Ping
10
+
11
+ from blueticks._errors import BluetickError
12
+ from blueticks._transport import Transport
13
+ from blueticks._version import __version__
14
+
15
+ _DEFAULT_BASE_URL = "https://api.blueticks.co"
16
+ _DEFAULT_TIMEOUT = 30.0
17
+ _DEFAULT_MAX_RETRIES = 2
18
+
19
+
20
+ class Blueticks:
21
+ """Synchronous client for the Blueticks API."""
22
+
23
+ def __init__(
24
+ self,
25
+ api_key: str | None = None,
26
+ *,
27
+ base_url: str | None = None,
28
+ timeout: float = _DEFAULT_TIMEOUT,
29
+ max_retries: int = _DEFAULT_MAX_RETRIES,
30
+ user_agent: str | None = None,
31
+ _http_transport: httpx.BaseTransport | None = None,
32
+ ) -> None:
33
+ resolved_key = api_key if api_key is not None else os.environ.get("BLUETICKS_API_KEY")
34
+ if not resolved_key:
35
+ raise BluetickError(
36
+ status_code=None,
37
+ code="authentication_required",
38
+ message=(
39
+ "No api_key provided. Pass Blueticks(api_key=...) "
40
+ "or set BLUETICKS_API_KEY in your environment."
41
+ ),
42
+ request_id=None,
43
+ )
44
+ self._api_key = resolved_key
45
+
46
+ resolved_base = (
47
+ base_url
48
+ if base_url is not None
49
+ else os.environ.get("BLUETICKS_BASE_URL") or _DEFAULT_BASE_URL
50
+ )
51
+ self._base_url = resolved_base
52
+
53
+ self._transport = Transport(
54
+ api_key=self._api_key,
55
+ base_url=self._base_url,
56
+ timeout=timeout,
57
+ max_retries=max_retries,
58
+ user_agent_suffix=user_agent,
59
+ version=__version__,
60
+ http_transport=_http_transport,
61
+ )
62
+
63
+ # Lazy-imported to avoid import cycles and to keep core import cheap.
64
+ from blueticks.resources.account import AccountResource
65
+
66
+ self.account = AccountResource(self)
67
+
68
+ # -- Public API ----------------------------------------------------------
69
+
70
+ def ping(self) -> Ping:
71
+ from blueticks.resources.ping import PingResource
72
+
73
+ return PingResource(self).retrieve()
74
+
75
+ def close(self) -> None:
76
+ self._transport.close()
77
+
78
+ def __enter__(self) -> Blueticks:
79
+ return self
80
+
81
+ def __exit__(self, *_: Any) -> None:
82
+ self.close()
83
+
84
+ # -- Internal ------------------------------------------------------------
85
+
86
+ def _request(
87
+ self,
88
+ method: str,
89
+ path: str,
90
+ *,
91
+ params: dict[str, Any] | None = None,
92
+ body: dict[str, Any] | None = None,
93
+ idempotency_key: str | None = None,
94
+ ) -> Any:
95
+ return self._transport.request(
96
+ method,
97
+ path,
98
+ params=params,
99
+ body=body,
100
+ idempotency_key=idempotency_key,
101
+ )
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+
8
+ class BluetickError(Exception):
9
+ """Base class for all Blueticks client errors."""
10
+
11
+ status_code: int | None
12
+ code: str | None
13
+ message: str
14
+ request_id: str | None
15
+ response: httpx.Response | None
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ status_code: int | None = None,
21
+ code: str | None = None,
22
+ message: str = "",
23
+ request_id: str | None = None,
24
+ response: httpx.Response | None = None,
25
+ ) -> None:
26
+ self.status_code = status_code
27
+ self.code = code
28
+ self.message = message
29
+ self.request_id = request_id
30
+ self.response = response
31
+ super().__init__(str(self))
32
+
33
+ def __str__(self) -> str:
34
+ return f"{self.status_code} {self.code}: {self.message} (request_id={self.request_id})"
35
+
36
+
37
+ class AuthenticationError(BluetickError):
38
+ """401 — missing or invalid API key."""
39
+
40
+
41
+ class PermissionDeniedError(BluetickError):
42
+ """403 — the key lacks the required scope or workspace access."""
43
+
44
+
45
+ class NotFoundError(BluetickError):
46
+ """404 — the resource does not exist."""
47
+
48
+
49
+ class BadRequestError(BluetickError):
50
+ """400 / 422 — the request was malformed or failed validation."""
51
+
52
+
53
+ class RateLimitError(BluetickError):
54
+ """429 — the client is being throttled."""
55
+
56
+ retry_after: float | None
57
+
58
+ def __init__(self, *, retry_after: float | None = None, **kwargs: Any) -> None:
59
+ super().__init__(**kwargs)
60
+ self.retry_after = retry_after
61
+
62
+
63
+ class APIError(BluetickError):
64
+ """5xx or otherwise-unparseable error response from the API."""
65
+
66
+
67
+ class APIConnectionError(BluetickError):
68
+ """Network failure: DNS, TLS, timeout, connection reset."""
69
+
70
+
71
+ _STATUS_TO_CLASS: dict[int, type[BluetickError]] = {
72
+ 400: BadRequestError,
73
+ 401: AuthenticationError,
74
+ 403: PermissionDeniedError,
75
+ 404: NotFoundError,
76
+ 422: BadRequestError,
77
+ 429: RateLimitError,
78
+ }
79
+
80
+
81
+ def _class_for_status(status: int) -> type[BluetickError]:
82
+ return _STATUS_TO_CLASS.get(status, APIError)
83
+
84
+
85
+ def from_envelope(
86
+ *,
87
+ status_code: int,
88
+ body: Any,
89
+ response: httpx.Response | None,
90
+ retry_after: float | None = None,
91
+ ) -> BluetickError:
92
+ """Map a non-2xx response body to a typed Blueticks exception."""
93
+ cls = _class_for_status(status_code)
94
+
95
+ error = body.get("error") if isinstance(body, dict) else None
96
+ if isinstance(error, dict) and isinstance(error.get("code"), str):
97
+ kwargs: dict[str, Any] = {
98
+ "status_code": status_code,
99
+ "code": error.get("code"),
100
+ "message": error.get("message", ""),
101
+ "request_id": error.get("request_id"),
102
+ "response": response,
103
+ }
104
+ if cls is RateLimitError:
105
+ kwargs["retry_after"] = retry_after
106
+ return cls(**kwargs)
107
+
108
+ # Fallback: unknown shape. Render the body as a truncated string.
109
+ raw = body if isinstance(body, str) else repr(body)
110
+ truncated = raw[:200] + ("..." if len(raw) > 200 else "")
111
+ kwargs = {
112
+ "status_code": status_code,
113
+ "code": None,
114
+ "message": truncated,
115
+ "request_id": None,
116
+ "response": response,
117
+ }
118
+ if cls is RateLimitError:
119
+ kwargs["retry_after"] = retry_after
120
+ return cls(**kwargs)
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ import time
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from blueticks._errors import (
11
+ APIConnectionError,
12
+ from_envelope,
13
+ )
14
+
15
+ _RETRIABLE_STATUS = {429, 502, 503, 504}
16
+ _IDEMPOTENT_METHODS = {"GET", "HEAD", "OPTIONS", "DELETE", "PATCH", "PUT"}
17
+ # Note: PATCH is not strictly idempotent per RFC 5789 but most REST APIs treat
18
+ # partial updates as idempotent in practice. Matches Stripe/OpenAI convention.
19
+ _BACKOFF_BASE_SECONDS = 0.5
20
+ _BACKOFF_CAP_SECONDS = 8.0
21
+
22
+
23
+ class Transport:
24
+ """HTTP transport for the Blueticks API. Wraps httpx.Client with retries."""
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ api_key: str,
30
+ base_url: str,
31
+ timeout: float,
32
+ max_retries: int,
33
+ user_agent_suffix: str | None,
34
+ version: str,
35
+ http_transport: httpx.BaseTransport | None = None,
36
+ ) -> None:
37
+ self._api_key = api_key
38
+ self._base_url = base_url.rstrip("/")
39
+ self._max_retries = max_retries
40
+ self._version = version
41
+ self._user_agent = f"blueticks-python/{version}"
42
+ if user_agent_suffix:
43
+ self._user_agent = f"{self._user_agent} {user_agent_suffix}"
44
+ self._client = httpx.Client(
45
+ base_url=self._base_url,
46
+ timeout=timeout,
47
+ transport=http_transport,
48
+ )
49
+
50
+ def close(self) -> None:
51
+ self._client.close()
52
+
53
+ def request(
54
+ self,
55
+ method: str,
56
+ path: str,
57
+ *,
58
+ params: dict[str, Any] | None,
59
+ body: dict[str, Any] | None,
60
+ idempotency_key: str | None = None,
61
+ ) -> Any:
62
+ method = method.upper()
63
+ headers: dict[str, str] = {
64
+ "Authorization": f"Bearer {self._api_key}",
65
+ "User-Agent": self._user_agent,
66
+ "Accept": "application/json",
67
+ }
68
+ if body is not None:
69
+ headers["Content-Type"] = "application/json"
70
+ if idempotency_key:
71
+ headers["Idempotency-Key"] = idempotency_key
72
+
73
+ is_idempotent = method in _IDEMPOTENT_METHODS or idempotency_key is not None
74
+
75
+ attempt = 0
76
+ while True:
77
+ try:
78
+ response = self._client.request(
79
+ method,
80
+ path,
81
+ params=params,
82
+ content=json.dumps(body).encode() if body is not None else None,
83
+ headers=headers,
84
+ )
85
+ except httpx.TransportError as exc:
86
+ if attempt < self._max_retries:
87
+ self._sleep_backoff(attempt, retry_after=None)
88
+ attempt += 1
89
+ continue
90
+ raise APIConnectionError(
91
+ status_code=None,
92
+ code=None,
93
+ message=f"connection error: {exc}",
94
+ request_id=None,
95
+ response=None,
96
+ ) from exc
97
+
98
+ if 200 <= response.status_code < 300:
99
+ if not response.content:
100
+ return None
101
+ return response.json()
102
+
103
+ parsed_body: Any
104
+ try:
105
+ parsed_body = response.json()
106
+ except (json.JSONDecodeError, ValueError):
107
+ parsed_body = response.text
108
+
109
+ retry_after = self._parse_retry_after(response) if response.status_code == 429 else None
110
+
111
+ retriable_status = response.status_code in _RETRIABLE_STATUS
112
+ if retriable_status and is_idempotent and attempt < self._max_retries:
113
+ self._sleep_backoff(attempt, retry_after=retry_after)
114
+ attempt += 1
115
+ continue
116
+
117
+ raise from_envelope(
118
+ status_code=response.status_code,
119
+ body=parsed_body,
120
+ response=response,
121
+ retry_after=retry_after,
122
+ )
123
+
124
+ @staticmethod
125
+ def _parse_retry_after(response: httpx.Response) -> float | None:
126
+ raw = response.headers.get("retry-after")
127
+ if raw is None:
128
+ return None
129
+ try:
130
+ return float(raw)
131
+ except ValueError:
132
+ return None
133
+
134
+ @staticmethod
135
+ def _sleep_backoff(attempt: int, *, retry_after: float | None) -> None:
136
+ if retry_after is not None:
137
+ time.sleep(retry_after)
138
+ return
139
+ backoff = min(_BACKOFF_BASE_SECONDS * (2**attempt), _BACKOFF_CAP_SECONDS)
140
+ jitter = random.uniform(0, _BACKOFF_BASE_SECONDS)
141
+ time.sleep(backoff + jitter)
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
File without changes
@@ -0,0 +1,4 @@
1
+ from blueticks.resources.account import AccountResource
2
+ from blueticks.resources.ping import PingResource
3
+
4
+ __all__ = ["AccountResource", "PingResource"]
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from blueticks._base_resource import BaseResource
4
+ from blueticks.types.account import Account
5
+
6
+
7
+ class AccountResource(BaseResource):
8
+ def retrieve(self) -> Account:
9
+ """Retrieve the authenticated account.
10
+
11
+ Returns the account associated with the API key used for this request.
12
+ """
13
+ data = self._client._request("GET", "/v1/account")
14
+ return Account.model_validate(data)
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from blueticks._base_resource import BaseResource
4
+ from blueticks.types.ping import Ping
5
+
6
+
7
+ class PingResource(BaseResource):
8
+ def retrieve(self) -> Ping:
9
+ """Health check.
10
+
11
+ Returns basic info about the authenticated API key.
12
+ """
13
+ data = self._client._request("GET", "/v1/ping")
14
+ return Ping.model_validate(data)
@@ -0,0 +1,4 @@
1
+ from blueticks.types.account import Account
2
+ from blueticks.types.ping import Ping
3
+
4
+ __all__ = ["Account", "Ping"]
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+
9
+ class Account(BaseModel):
10
+ """Response from GET /v1/account."""
11
+
12
+ model_config = ConfigDict(extra="ignore")
13
+
14
+ id: str
15
+ name: str
16
+ timezone: Optional[str] = None # noqa: UP045
17
+ created_at: datetime
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class Ping(BaseModel):
7
+ """Response from GET /v1/ping."""
8
+
9
+ model_config = ConfigDict(extra="ignore")
10
+
11
+ account_id: str
12
+ key_prefix: str
13
+ scopes: list[str]
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: blueticks
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the Blueticks API.
5
+ Author-email: Blueticks <support@blueticks.co>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 serenix-com
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://blueticks.co
29
+ Project-URL: Documentation, https://docs.blueticks.co
30
+ Project-URL: Source, https://github.com/serenix-com/blueticks-python
31
+ Project-URL: Changelog, https://github.com/serenix-com/blueticks-python/blob/main/CHANGELOG.md
32
+ Keywords: blueticks,whatsapp,api,sdk
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Requires-Python: >=3.9
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Requires-Dist: httpx<1.0,>=0.27
46
+ Requires-Dist: pydantic<3.0,>=2.6
47
+ Provides-Extra: dev
48
+ Requires-Dist: pytest>=8.0; extra == "dev"
49
+ Requires-Dist: pytest-httpx>=0.30; extra == "dev"
50
+ Requires-Dist: ruff>=0.5; extra == "dev"
51
+ Requires-Dist: mypy>=1.10; extra == "dev"
52
+ Dynamic: license-file
53
+
54
+ # blueticks — Python client for the Blueticks API
55
+
56
+ Official Python SDK for [Blueticks](https://blueticks.co).
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install blueticks
62
+ ```
63
+
64
+ ## Quickstart
65
+
66
+ ```python
67
+ from blueticks import Blueticks
68
+
69
+ client = Blueticks(api_key="bt_live_...")
70
+ ping = client.ping()
71
+ account = client.account.retrieve()
72
+ print(account.name)
73
+ ```
74
+
75
+ See https://docs.blueticks.co for full documentation.
@@ -0,0 +1,26 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/blueticks/__init__.py
5
+ src/blueticks/_base_resource.py
6
+ src/blueticks/_client.py
7
+ src/blueticks/_errors.py
8
+ src/blueticks/_transport.py
9
+ src/blueticks/_version.py
10
+ src/blueticks/py.typed
11
+ src/blueticks.egg-info/PKG-INFO
12
+ src/blueticks.egg-info/SOURCES.txt
13
+ src/blueticks.egg-info/dependency_links.txt
14
+ src/blueticks.egg-info/requires.txt
15
+ src/blueticks.egg-info/top_level.txt
16
+ src/blueticks/resources/__init__.py
17
+ src/blueticks/resources/account.py
18
+ src/blueticks/resources/ping.py
19
+ src/blueticks/types/__init__.py
20
+ src/blueticks/types/account.py
21
+ src/blueticks/types/ping.py
22
+ tests/test_account.py
23
+ tests/test_client.py
24
+ tests/test_errors.py
25
+ tests/test_ping.py
26
+ tests/test_transport.py
@@ -0,0 +1,8 @@
1
+ httpx<1.0,>=0.27
2
+ pydantic<3.0,>=2.6
3
+
4
+ [dev]
5
+ pytest>=8.0
6
+ pytest-httpx>=0.30
7
+ ruff>=0.5
8
+ mypy>=1.10
@@ -0,0 +1 @@
1
+ blueticks
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+
6
+ import httpx
7
+ import pytest
8
+ from pydantic import ValidationError
9
+
10
+ from blueticks import AuthenticationError
11
+ from blueticks.types.account import Account
12
+
13
+
14
+ def test_account_retrieve_returns_typed_model(mock_client) -> None:
15
+ def handler(request: httpx.Request) -> httpx.Response:
16
+ assert request.method == "GET"
17
+ assert request.url.path == "/v1/account"
18
+ return httpx.Response(
19
+ 200,
20
+ content=json.dumps(
21
+ {
22
+ "id": "acc_1",
23
+ "name": "Acme",
24
+ "timezone": "America/New_York",
25
+ "created_at": "2026-04-22T10:00:00Z",
26
+ }
27
+ ).encode(),
28
+ headers={"content-type": "application/json"},
29
+ )
30
+
31
+ with mock_client(handler) as client:
32
+ result = client.account.retrieve()
33
+ assert isinstance(result, Account)
34
+ assert result.id == "acc_1"
35
+ assert result.name == "Acme"
36
+ assert result.timezone == "America/New_York"
37
+ assert isinstance(result.created_at, datetime)
38
+
39
+
40
+ def test_account_retrieve_accepts_null_timezone(mock_client) -> None:
41
+ def handler(_request: httpx.Request) -> httpx.Response:
42
+ return httpx.Response(
43
+ 200,
44
+ content=json.dumps(
45
+ {
46
+ "id": "acc_2",
47
+ "name": "Nobody",
48
+ "timezone": None,
49
+ "created_at": "2026-01-01T00:00:00Z",
50
+ }
51
+ ).encode(),
52
+ headers={"content-type": "application/json"},
53
+ )
54
+
55
+ with mock_client(handler) as client:
56
+ result = client.account.retrieve()
57
+ assert result.timezone is None
58
+
59
+
60
+ def test_account_retrieve_propagates_authentication_error(mock_client) -> None:
61
+ def handler(_request: httpx.Request) -> httpx.Response:
62
+ body = {
63
+ "error": {
64
+ "code": "authentication_required",
65
+ "message": "bad key",
66
+ "request_id": "req_a",
67
+ }
68
+ }
69
+ return httpx.Response(
70
+ 401,
71
+ content=json.dumps(body).encode(),
72
+ headers={"content-type": "application/json"},
73
+ )
74
+
75
+ with mock_client(handler) as client:
76
+ with pytest.raises(AuthenticationError) as info:
77
+ client.account.retrieve()
78
+ assert info.value.code == "authentication_required"
79
+ assert info.value.message == "bad key"
80
+ assert info.value.request_id == "req_a"
81
+
82
+
83
+ def test_account_retrieve_missing_required_field_raises_validation_error(mock_client) -> None:
84
+ def handler(_request: httpx.Request) -> httpx.Response:
85
+ return httpx.Response(200, content=b"{}", headers={"content-type": "application/json"})
86
+
87
+ with mock_client(handler) as client:
88
+ with pytest.raises(ValidationError):
89
+ client.account.retrieve()
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from blueticks import Blueticks
6
+ from blueticks._errors import BluetickError
7
+
8
+
9
+ def test_constructor_accepts_api_key() -> None:
10
+ c = Blueticks(api_key="bt_live_x")
11
+ assert c._api_key == "bt_live_x"
12
+ c.close()
13
+
14
+
15
+ def test_constructor_falls_back_to_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
16
+ monkeypatch.setenv("BLUETICKS_API_KEY", "bt_test_env")
17
+ c = Blueticks()
18
+ assert c._api_key == "bt_test_env"
19
+ c.close()
20
+
21
+
22
+ def test_constructor_raises_when_no_key(monkeypatch: pytest.MonkeyPatch) -> None:
23
+ monkeypatch.delenv("BLUETICKS_API_KEY", raising=False)
24
+ with pytest.raises(BluetickError) as info:
25
+ Blueticks()
26
+ assert "api_key" in str(info.value).lower() or "BLUETICKS_API_KEY" in str(info.value)
27
+
28
+
29
+ def test_base_url_env_var_overrides_default(monkeypatch: pytest.MonkeyPatch) -> None:
30
+ monkeypatch.setenv("BLUETICKS_BASE_URL", "https://staging.example.test")
31
+ c = Blueticks(api_key="k")
32
+ assert c._base_url == "https://staging.example.test"
33
+ c.close()
34
+
35
+
36
+ def test_explicit_base_url_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None:
37
+ monkeypatch.setenv("BLUETICKS_BASE_URL", "https://env.example.test")
38
+ c = Blueticks(api_key="k", base_url="https://explicit.example.test")
39
+ assert c._base_url == "https://explicit.example.test"
40
+ c.close()
41
+
42
+
43
+ def test_context_manager_closes_transport() -> None:
44
+ with Blueticks(api_key="k") as c:
45
+ transport = c._transport
46
+ assert transport._client.is_closed
47
+
48
+
49
+ def test_default_base_url() -> None:
50
+ c = Blueticks(api_key="k")
51
+ assert c._base_url == "https://api.blueticks.co"
52
+ c.close()
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from blueticks._errors import (
6
+ APIConnectionError,
7
+ APIError,
8
+ AuthenticationError,
9
+ BadRequestError,
10
+ BluetickError,
11
+ NotFoundError,
12
+ PermissionDeniedError,
13
+ RateLimitError,
14
+ from_envelope,
15
+ )
16
+
17
+
18
+ def test_base_class_is_exception() -> None:
19
+ assert issubclass(BluetickError, Exception)
20
+
21
+
22
+ def test_all_subclasses_inherit_bluetick_error() -> None:
23
+ for cls in [
24
+ AuthenticationError,
25
+ PermissionDeniedError,
26
+ NotFoundError,
27
+ BadRequestError,
28
+ RateLimitError,
29
+ APIError,
30
+ APIConnectionError,
31
+ ]:
32
+ assert issubclass(cls, BluetickError)
33
+
34
+
35
+ def test_str_renders_status_code_and_request_id() -> None:
36
+ err = BluetickError(
37
+ status_code=401,
38
+ code="authentication_required",
39
+ message="bad key",
40
+ request_id="req_123",
41
+ )
42
+ assert str(err) == "401 authentication_required: bad key (request_id=req_123)"
43
+
44
+
45
+ def test_str_handles_missing_request_id() -> None:
46
+ err = BluetickError(status_code=500, code="internal_error", message="boom", request_id=None)
47
+ assert "request_id=None" in str(err)
48
+
49
+
50
+ @pytest.mark.parametrize(
51
+ "status,code,expected_cls",
52
+ [
53
+ (401, "authentication_required", AuthenticationError),
54
+ (403, "permission_denied", PermissionDeniedError),
55
+ (404, "not_found", NotFoundError),
56
+ (400, "invalid_request", BadRequestError),
57
+ (422, "invalid_request", BadRequestError),
58
+ (429, "rate_limited", RateLimitError),
59
+ (500, "internal_error", APIError),
60
+ (503, "internal_error", APIError),
61
+ ],
62
+ )
63
+ def test_from_envelope_maps_status_to_typed_exception(
64
+ status: int, code: str, expected_cls: type[BluetickError]
65
+ ) -> None:
66
+ err = from_envelope(
67
+ status_code=status,
68
+ body={"error": {"code": code, "message": "m", "request_id": "r"}},
69
+ response=None,
70
+ )
71
+ assert isinstance(err, expected_cls)
72
+ assert err.status_code == status
73
+ assert err.code == code
74
+ assert err.message == "m"
75
+ assert err.request_id == "r"
76
+
77
+
78
+ def test_from_envelope_falls_back_when_body_has_no_error_key() -> None:
79
+ err = from_envelope(status_code=502, body={"weird": "shape"}, response=None)
80
+ assert isinstance(err, APIError)
81
+ assert err.code is None
82
+ # raw body, truncated
83
+ assert "weird" in err.message
84
+
85
+
86
+ def test_from_envelope_truncates_long_bodies() -> None:
87
+ big = "x" * 500
88
+ err = from_envelope(status_code=500, body=big, response=None)
89
+ assert err.message.endswith("...")
90
+ assert len(err.message) == 203 # 200 chars + 3-char ellipsis
91
+
92
+
93
+ def test_rate_limit_error_carries_retry_after() -> None:
94
+ err = RateLimitError(
95
+ status_code=429,
96
+ code="rate_limited",
97
+ message="slow down",
98
+ request_id=None,
99
+ retry_after=30.0,
100
+ )
101
+ assert err.retry_after == 30.0
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+ from pydantic import ValidationError
8
+
9
+ from blueticks import AuthenticationError
10
+ from blueticks.types.ping import Ping
11
+
12
+
13
+ def test_ping_retrieve_returns_typed_model(mock_client) -> None:
14
+ def handler(request: httpx.Request) -> httpx.Response:
15
+ assert request.method == "GET"
16
+ assert request.url.path == "/v1/ping"
17
+ return httpx.Response(
18
+ 200,
19
+ content=json.dumps(
20
+ {
21
+ "account_id": "acc_abc",
22
+ "key_prefix": "xy12ab34",
23
+ "scopes": ["messages:read"],
24
+ }
25
+ ).encode(),
26
+ headers={"content-type": "application/json"},
27
+ )
28
+
29
+ with mock_client(handler) as client:
30
+ result = client.ping()
31
+ assert isinstance(result, Ping)
32
+ assert result.account_id == "acc_abc"
33
+ assert result.key_prefix == "xy12ab34"
34
+ assert result.scopes == ["messages:read"]
35
+
36
+
37
+ def test_ping_propagates_authentication_error(mock_client) -> None:
38
+ def handler(_request: httpx.Request) -> httpx.Response:
39
+ body = {
40
+ "error": {
41
+ "code": "authentication_required",
42
+ "message": "bad key",
43
+ "request_id": "req_1",
44
+ }
45
+ }
46
+ return httpx.Response(
47
+ 401,
48
+ content=json.dumps(body).encode(),
49
+ headers={"content-type": "application/json"},
50
+ )
51
+
52
+ with mock_client(handler) as client:
53
+ with pytest.raises(AuthenticationError) as info:
54
+ client.ping()
55
+ assert info.value.code == "authentication_required"
56
+ assert info.value.message == "bad key"
57
+ assert info.value.request_id == "req_1"
58
+
59
+
60
+ def test_ping_missing_required_field_raises_validation_error(mock_client) -> None:
61
+ def handler(_request: httpx.Request) -> httpx.Response:
62
+ return httpx.Response(200, content=b"{}", headers={"content-type": "application/json"})
63
+
64
+ with mock_client(handler) as client:
65
+ with pytest.raises(ValidationError):
66
+ client.ping()
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from blueticks._errors import (
10
+ APIConnectionError,
11
+ APIError,
12
+ AuthenticationError,
13
+ RateLimitError,
14
+ )
15
+ from blueticks._transport import Transport
16
+
17
+
18
+ def _envelope(code: str, message: str = "x", request_id: str = "req_1") -> bytes:
19
+ body = {"error": {"code": code, "message": message, "request_id": request_id}}
20
+ return json.dumps(body).encode()
21
+
22
+
23
+ def _ok(body: Any) -> bytes:
24
+ return json.dumps(body).encode()
25
+
26
+
27
+ def _mock(handler):
28
+ return httpx.MockTransport(handler)
29
+
30
+
31
+ def _make_transport(mock: httpx.MockTransport, *, max_retries: int = 2) -> Transport:
32
+ return Transport(
33
+ api_key="bt_test_abc",
34
+ base_url="https://example.test",
35
+ timeout=5.0,
36
+ max_retries=max_retries,
37
+ user_agent_suffix=None,
38
+ version="1.0.0",
39
+ http_transport=mock,
40
+ )
41
+
42
+
43
+ def test_get_returns_parsed_json_on_200() -> None:
44
+ def handler(request: httpx.Request) -> httpx.Response:
45
+ assert request.headers["authorization"] == "Bearer bt_test_abc"
46
+ assert "blueticks-python/1.0.0" in request.headers["user-agent"]
47
+ return httpx.Response(200, content=_ok({"hello": "world"}))
48
+
49
+ transport = _make_transport(_mock(handler))
50
+ result = transport.request("GET", "/v1/ping", params=None, body=None)
51
+ assert result == {"hello": "world"}
52
+
53
+
54
+ def test_401_raises_authentication_error() -> None:
55
+ def handler(_request: httpx.Request) -> httpx.Response:
56
+ return httpx.Response(401, content=_envelope("authentication_required", "bad"))
57
+
58
+ transport = _make_transport(_mock(handler))
59
+ with pytest.raises(AuthenticationError) as info:
60
+ transport.request("GET", "/v1/ping", params=None, body=None)
61
+ assert info.value.code == "authentication_required"
62
+ assert info.value.message == "bad"
63
+
64
+
65
+ def test_non_json_body_still_raises_api_error() -> None:
66
+ def handler(_request: httpx.Request) -> httpx.Response:
67
+ return httpx.Response(502, content=b"<html>bad gateway</html>")
68
+
69
+ transport = _make_transport(_mock(handler))
70
+ with pytest.raises(APIError) as info:
71
+ transport.request("GET", "/v1/ping", params=None, body=None)
72
+ assert info.value.status_code == 502
73
+
74
+
75
+ def test_429_with_retry_after_retries_then_succeeds() -> None:
76
+ calls = {"n": 0}
77
+
78
+ def handler(_request: httpx.Request) -> httpx.Response:
79
+ calls["n"] += 1
80
+ if calls["n"] == 1:
81
+ return httpx.Response(
82
+ 429,
83
+ headers={"retry-after": "0"},
84
+ content=_envelope("rate_limited", "slow"),
85
+ )
86
+ return httpx.Response(200, content=_ok({"ok": True}))
87
+
88
+ transport = _make_transport(_mock(handler), max_retries=3)
89
+ result = transport.request("GET", "/v1/ping", params=None, body=None)
90
+ assert result == {"ok": True}
91
+ assert calls["n"] == 2
92
+
93
+
94
+ def test_429_exhausts_retries_and_raises_rate_limit() -> None:
95
+ def handler(_request: httpx.Request) -> httpx.Response:
96
+ return httpx.Response(429, headers={"retry-after": "0"}, content=_envelope("rate_limited"))
97
+
98
+ transport = _make_transport(_mock(handler), max_retries=1)
99
+ with pytest.raises(RateLimitError) as info:
100
+ transport.request("GET", "/v1/ping", params=None, body=None)
101
+ assert info.value.retry_after == 0.0
102
+
103
+
104
+ def test_503_retries_then_succeeds() -> None:
105
+ calls = {"n": 0}
106
+
107
+ def handler(_request: httpx.Request) -> httpx.Response:
108
+ calls["n"] += 1
109
+ if calls["n"] == 1:
110
+ return httpx.Response(503, content=_envelope("internal_error"))
111
+ return httpx.Response(200, content=_ok({"ok": True}))
112
+
113
+ transport = _make_transport(_mock(handler), max_retries=2)
114
+ result = transport.request("GET", "/v1/ping", params=None, body=None)
115
+ assert result == {"ok": True}
116
+
117
+
118
+ def test_post_does_not_retry_on_5xx_without_idempotency_key() -> None:
119
+ calls = {"n": 0}
120
+
121
+ def handler(_request: httpx.Request) -> httpx.Response:
122
+ calls["n"] += 1
123
+ return httpx.Response(503, content=_envelope("internal_error"))
124
+
125
+ transport = _make_transport(_mock(handler), max_retries=3)
126
+ with pytest.raises(APIError):
127
+ transport.request("POST", "/v1/messages", params=None, body={"x": 1})
128
+ assert calls["n"] == 1
129
+
130
+
131
+ def test_connection_error_retries() -> None:
132
+ calls = {"n": 0}
133
+
134
+ def handler(_request: httpx.Request) -> httpx.Response:
135
+ calls["n"] += 1
136
+ if calls["n"] == 1:
137
+ raise httpx.ConnectError("dns fail")
138
+ return httpx.Response(200, content=_ok({"ok": True}))
139
+
140
+ transport = _make_transport(_mock(handler), max_retries=2)
141
+ result = transport.request("GET", "/v1/ping", params=None, body=None)
142
+ assert result == {"ok": True}
143
+
144
+
145
+ def test_connection_error_exhausted_raises_api_connection_error() -> None:
146
+ def handler(_request: httpx.Request) -> httpx.Response:
147
+ raise httpx.ConnectError("dns fail")
148
+
149
+ transport = _make_transport(_mock(handler), max_retries=1)
150
+ with pytest.raises(APIConnectionError):
151
+ transport.request("GET", "/v1/ping", params=None, body=None)
152
+
153
+
154
+ def test_user_agent_includes_suffix() -> None:
155
+ captured: dict[str, str] = {}
156
+
157
+ def handler(request: httpx.Request) -> httpx.Response:
158
+ captured["ua"] = request.headers["user-agent"]
159
+ return httpx.Response(200, content=_ok({}))
160
+
161
+ t = Transport(
162
+ api_key="k",
163
+ base_url="https://example.test",
164
+ timeout=5.0,
165
+ max_retries=0,
166
+ user_agent_suffix="myapp/1.2",
167
+ version="1.0.0",
168
+ http_transport=_mock(handler),
169
+ )
170
+ t.request("GET", "/v1/ping", params=None, body=None)
171
+ assert captured["ua"] == "blueticks-python/1.0.0 myapp/1.2"
172
+
173
+
174
+ def test_query_params_are_sent() -> None:
175
+ captured: dict[str, Any] = {}
176
+
177
+ def handler(request: httpx.Request) -> httpx.Response:
178
+ captured["url"] = str(request.url)
179
+ return httpx.Response(200, content=_ok({}))
180
+
181
+ transport = _make_transport(_mock(handler))
182
+ transport.request("GET", "/v1/messages", params={"limit": 10}, body=None)
183
+ assert "limit=10" in captured["url"]
184
+
185
+
186
+ def test_body_is_json_encoded() -> None:
187
+ captured: dict[str, Any] = {}
188
+
189
+ def handler(request: httpx.Request) -> httpx.Response:
190
+ captured["body"] = request.content
191
+ return httpx.Response(200, content=_ok({}))
192
+
193
+ transport = _make_transport(_mock(handler))
194
+ transport.request("POST", "/v1/messages", params=None, body={"text": "hi"})
195
+ assert json.loads(captured["body"]) == {"text": "hi"}