nixflex 0.1.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,14 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python-version: ["3.9", "3.12", "3.13"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with: { python-version: "${{ matrix.python-version }}" }
13
+ - run: pip install -e ".[dev]"
14
+ - run: python -m pytest -q
@@ -0,0 +1,6 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .venv/
@@ -0,0 +1,2 @@
1
+ ## 0.1.0
2
+ - First release. Sync (`Nixflex`) and async (`AsyncNixflex`) clients covering every endpoint - 46 methods mirroring the Node SDK. Retry policy identical to Node (429 with Retry-After, network once, 5xx only on GET/DELETE, POST never blind-retried). Eight typed error classes. `verify_webhook_signature`. Python 3.9+, `httpx` only.
nixflex-0.1.0/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nixflex Enterprises LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
nixflex-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.5
2
+ Name: nixflex
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Nixflex voice AI platform - AI phone agents, outbound campaigns, SMS and caller context.
5
+ Project-URL: Homepage, https://nixflex.com
6
+ Project-URL: Documentation, https://docs.nixflex.com/sdks/python
7
+ Project-URL: Repository, https://github.com/nixflex/nixflex-python
8
+ Project-URL: Changelog, https://github.com/nixflex/nixflex-python/blob/main/CHANGELOG.md
9
+ Author-email: Nixflex <stackadvisor.app@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-receptionist,nixflex,phone,sdk,sms,telephony,telnyx,twilio,voice-agent,voice-ai
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Communications :: Telephony
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx<2,>=0.27
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
28
+ Requires-Dist: pytest>=8; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # nixflex
32
+
33
+ Official Python SDK for the [Nixflex](https://nixflex.com) voice AI platform - AI phone agents, outbound campaigns, SMS and caller context.
34
+
35
+ ```bash
36
+ pip install nixflex
37
+ ```
38
+
39
+ ```python
40
+ from nixflex import Nixflex
41
+
42
+ client = Nixflex(api_key="nxf_xxx:nxfs_xxx") # both halves, joined by a colon
43
+
44
+ call = client.calls.create(
45
+ agent_id="agent_15d1a9ee16294087",
46
+ to_number="+447700900123",
47
+ prompt="Call Sam to confirm his appointment on Tuesday at 11.",
48
+ )
49
+ print(call["call_id"])
50
+ ```
51
+
52
+ Async:
53
+
54
+ ```python
55
+ from nixflex import AsyncNixflex
56
+
57
+ async with AsyncNixflex(api_key="nxf_xxx:nxfs_xxx") as client:
58
+ agents = await client.agents.list()
59
+ ```
60
+
61
+ - Every endpoint: `agents`, `calls`, `campaigns`, `phone_numbers`, `callers` (caller context), `sms` (+ `sms.campaigns`), `keys`, `usage`, `webhooks`, `storage`, `llm`, `tts` - the same names as the Node SDK and the CLI.
62
+ - Retries: a 429 is retried once honouring `Retry-After`; network failures retry once; 5xx retries only `GET`/`DELETE`. A `POST` is never blind-retried - a call is never dialled twice.
63
+ - Typed errors: `NixflexAuthenticationError`, `NixflexRateLimitError` (`.retry_after_seconds`), `NixflexNotFoundError`, ...
64
+ - `verify_webhook_signature(raw_body, header, key_secret)` for signed webhooks.
65
+ - Python 3.9+, one dependency (`httpx`).
66
+
67
+ Docs: https://docs.nixflex.com/sdks/python
@@ -0,0 +1,37 @@
1
+ # nixflex
2
+
3
+ Official Python SDK for the [Nixflex](https://nixflex.com) voice AI platform - AI phone agents, outbound campaigns, SMS and caller context.
4
+
5
+ ```bash
6
+ pip install nixflex
7
+ ```
8
+
9
+ ```python
10
+ from nixflex import Nixflex
11
+
12
+ client = Nixflex(api_key="nxf_xxx:nxfs_xxx") # both halves, joined by a colon
13
+
14
+ call = client.calls.create(
15
+ agent_id="agent_15d1a9ee16294087",
16
+ to_number="+447700900123",
17
+ prompt="Call Sam to confirm his appointment on Tuesday at 11.",
18
+ )
19
+ print(call["call_id"])
20
+ ```
21
+
22
+ Async:
23
+
24
+ ```python
25
+ from nixflex import AsyncNixflex
26
+
27
+ async with AsyncNixflex(api_key="nxf_xxx:nxfs_xxx") as client:
28
+ agents = await client.agents.list()
29
+ ```
30
+
31
+ - Every endpoint: `agents`, `calls`, `campaigns`, `phone_numbers`, `callers` (caller context), `sms` (+ `sms.campaigns`), `keys`, `usage`, `webhooks`, `storage`, `llm`, `tts` - the same names as the Node SDK and the CLI.
32
+ - Retries: a 429 is retried once honouring `Retry-After`; network failures retry once; 5xx retries only `GET`/`DELETE`. A `POST` is never blind-retried - a call is never dialled twice.
33
+ - Typed errors: `NixflexAuthenticationError`, `NixflexRateLimitError` (`.retry_after_seconds`), `NixflexNotFoundError`, ...
34
+ - `verify_webhook_signature(raw_body, header, key_secret)` for signed webhooks.
35
+ - Python 3.9+, one dependency (`httpx`).
36
+
37
+ Docs: https://docs.nixflex.com/sdks/python
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nixflex"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Nixflex voice AI platform - AI phone agents, outbound campaigns, SMS and caller context."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Nixflex", email = "stackadvisor.app@gmail.com" }]
13
+ keywords = ["nixflex", "voice-ai", "voice-agent", "phone", "telephony", "ai-receptionist", "twilio", "telnyx", "sms", "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
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Communications :: Telephony",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = ["httpx>=0.27,<2"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://nixflex.com"
31
+ Documentation = "https://docs.nixflex.com/sdks/python"
32
+ Repository = "https://github.com/nixflex/nixflex-python"
33
+ Changelog = "https://github.com/nixflex/nixflex-python/blob/main/CHANGELOG.md"
34
+
35
+ [project.optional-dependencies]
36
+ dev = ["pytest>=8", "pytest-asyncio>=0.23"]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/nixflex"]
40
+
41
+ [tool.pytest.ini_options]
42
+ asyncio_mode = "auto"
43
+ testpaths = ["tests"]
@@ -0,0 +1,94 @@
1
+ """Official Nixflex SDK for Python.
2
+
3
+ from nixflex import Nixflex
4
+ client = Nixflex(api_key="nxf_xxx:nxfs_xxx")
5
+ call = client.calls.create(agent_id="agent_x", to_number="+447700900123", prompt="...")
6
+
7
+ Async:
8
+ from nixflex import AsyncNixflex
9
+ async with AsyncNixflex(api_key=...) as client:
10
+ agents = await client.agents.list()
11
+
12
+ Every method mirrors the Node SDK and the CLI - same names in snake_case. The docs
13
+ (docs.nixflex.com) are the source of truth; this SDK changes in the same session as the API.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional
18
+
19
+ import httpx
20
+
21
+ from ._http import AsyncHttp, SyncHttp, __version__
22
+ from .errors import (
23
+ NixflexAuthenticationError,
24
+ NixflexConnectionError,
25
+ NixflexError,
26
+ NixflexInvalidRequestError,
27
+ NixflexNotFoundError,
28
+ NixflexPaymentRequiredError,
29
+ NixflexRateLimitError,
30
+ NixflexServerError,
31
+ )
32
+ from .resources.account import Keys, Llm, Storage, Tts, Usage, Webhooks
33
+ from .resources.agents import Agents
34
+ from .resources.callers import Callers
35
+ from .resources.calls import Calls, Campaigns
36
+ from .resources.phone_numbers import PhoneNumbers
37
+ from .resources.sms import Sms
38
+ from .webhook import verify_webhook_signature
39
+
40
+ __all__ = [
41
+ "Nixflex", "AsyncNixflex", "verify_webhook_signature", "__version__",
42
+ "NixflexError", "NixflexAuthenticationError", "NixflexPaymentRequiredError", "NixflexNotFoundError",
43
+ "NixflexRateLimitError", "NixflexInvalidRequestError", "NixflexServerError", "NixflexConnectionError",
44
+ ]
45
+
46
+
47
+ class _Resources:
48
+ def _attach(self, http) -> None:
49
+ self.agents = Agents(http)
50
+ self.calls = Calls(http)
51
+ self.campaigns = Campaigns(http)
52
+ self.phone_numbers = PhoneNumbers(http)
53
+ self.callers = Callers(http)
54
+ self.sms = Sms(http)
55
+ self.keys = Keys(http)
56
+ self.usage = Usage(http)
57
+ self.webhooks = Webhooks(http)
58
+ self.storage = Storage(http)
59
+ self.llm = Llm(http)
60
+ self.tts = Tts(http)
61
+
62
+
63
+ class Nixflex(_Resources):
64
+ """Synchronous client. Use as a context manager or call close() when done."""
65
+
66
+ def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.BaseTransport] = None):
67
+ self._http = SyncHttp(api_key, base_url, timeout, max_retries, transport)
68
+ self._attach(self._http)
69
+
70
+ def close(self) -> None:
71
+ self._http.close()
72
+
73
+ def __enter__(self) -> "Nixflex":
74
+ return self
75
+
76
+ def __exit__(self, *exc) -> None:
77
+ self.close()
78
+
79
+
80
+ class AsyncNixflex(_Resources):
81
+ """Asynchronous client - every method returns an awaitable."""
82
+
83
+ def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.AsyncBaseTransport] = None):
84
+ self._http = AsyncHttp(api_key, base_url, timeout, max_retries, transport)
85
+ self._attach(self._http)
86
+
87
+ async def close(self) -> None:
88
+ await self._http.close()
89
+
90
+ async def __aenter__(self) -> "AsyncNixflex":
91
+ return self
92
+
93
+ async def __aexit__(self, *exc) -> None:
94
+ await self.close()
@@ -0,0 +1,147 @@
1
+ """The core HTTP layer every resource uses - sync and async, one retry policy.
2
+
3
+ Behaviours (identical to the Node SDK, both verified against the live API):
4
+ - Bearer auth with the full key "key_id:key_secret".
5
+ - Per-request timeout (default 30 s).
6
+ - RETRY: a 429 honours the API's Retry-After header (capped 30 s). 5xx and network
7
+ failures retry once with a small backoff. GET/DELETE always retry; POST/PUT/PATCH
8
+ retry ONLY on 429 or a network failure before any response - never after a 5xx,
9
+ which may already have acted (no double calls, no double sends).
10
+ - Every non-2xx becomes a typed error (errors.py); the request id is surfaced.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import time
17
+ from typing import Any, Dict, Mapping, Optional
18
+ from urllib.parse import quote
19
+
20
+ import httpx
21
+
22
+ from .errors import NixflexConnectionError, error_from_response
23
+
24
+ __version__ = "0.1.0"
25
+ DEFAULT_BASE_URL = "https://api.nixflex.com"
26
+ DEFAULT_TIMEOUT = 30.0
27
+ _IDEMPOTENT = {"GET", "DELETE"}
28
+
29
+
30
+ def enc(number: str) -> str:
31
+ """E.164 numbers go in URL paths - the + MUST be encoded or routing breaks."""
32
+ return quote(number, safe="")
33
+
34
+
35
+ def _check_key(api_key: Any) -> str:
36
+ if not isinstance(api_key, str) or ":" not in api_key:
37
+ raise ValueError(
38
+ 'Nixflex: api_key is required in the form "key_id:key_secret" (both parts, joined by a colon). '
39
+ "Find yours at https://dashboard.nixflex.com under API Keys."
40
+ )
41
+ return api_key
42
+
43
+
44
+ def _clean_query(query: Optional[Mapping[str, Any]]) -> Dict[str, str]:
45
+ if not query:
46
+ return {}
47
+ return {k: str(v) for k, v in query.items() if v is not None}
48
+
49
+
50
+ def _retry_plan(status: int, method: str, retry_after: int, attempt: int, max_retries: int) -> Optional[float]:
51
+ """Seconds to sleep before retrying, or None = do not retry."""
52
+ if attempt > max_retries:
53
+ return None
54
+ if status == 429:
55
+ return float(min(retry_after, 30)) or 1.0
56
+ if status >= 500 and method in _IDEMPOTENT:
57
+ return 0.5 * attempt
58
+ return None
59
+
60
+
61
+ def _parse_error(res: httpx.Response):
62
+ request_id = res.headers.get("x-railway-request-id")
63
+ try:
64
+ retry_after = int(res.headers.get("retry-after") or "0")
65
+ except ValueError:
66
+ retry_after = 0
67
+ try:
68
+ body = res.json()
69
+ except Exception:
70
+ body = None
71
+ return request_id, retry_after, body
72
+
73
+
74
+ class _Base:
75
+ def __init__(self, api_key: str, base_url: Optional[str], timeout: Optional[float], max_retries: Optional[int]):
76
+ self._api_key = _check_key(api_key)
77
+ self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
78
+ self._timeout = DEFAULT_TIMEOUT if timeout is None else timeout
79
+ self._max_retries = 1 if max_retries is None else max_retries
80
+ self._headers = {
81
+ "Authorization": f"Bearer {self._api_key}",
82
+ "Content-Type": "application/json",
83
+ "User-Agent": f"nixflex-python/{__version__}",
84
+ }
85
+
86
+ def _url(self, path: str) -> str:
87
+ return self._base_url + "/v1" + path
88
+
89
+
90
+ class SyncHttp(_Base):
91
+ def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.BaseTransport] = None):
92
+ super().__init__(api_key, base_url, timeout, max_retries)
93
+ self._client = httpx.Client(timeout=self._timeout, transport=transport)
94
+
95
+ def close(self) -> None:
96
+ self._client.close()
97
+
98
+ def request(self, method: str, path: str, body: Any = None, query: Optional[Mapping[str, Any]] = None, timeout: Optional[float] = None) -> Any:
99
+ attempt = 0
100
+ content = None if body is None else json.dumps(body).encode("utf-8")
101
+ while True:
102
+ attempt += 1
103
+ try:
104
+ res = self._client.request(method, self._url(path), params=_clean_query(query), content=content, headers=self._headers, timeout=self._timeout if timeout is None else timeout)
105
+ except (httpx.TransportError, httpx.TimeoutException) as err:
106
+ if attempt <= self._max_retries:
107
+ time.sleep(0.3 * attempt)
108
+ continue
109
+ raise NixflexConnectionError(f"Could not reach the Nixflex API ({err.__class__.__name__}: {err}). Check connectivity and https://nixflex.com/status") from None
110
+ if res.is_success:
111
+ return None if res.status_code == 204 else res.json()
112
+ request_id, retry_after, err_body = _parse_error(res)
113
+ wait = _retry_plan(res.status_code, method, retry_after, attempt, self._max_retries)
114
+ if wait is not None:
115
+ time.sleep(wait)
116
+ continue
117
+ raise error_from_response(res.status_code, err_body, request_id, retry_after)
118
+
119
+
120
+ class AsyncHttp(_Base):
121
+ def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.AsyncBaseTransport] = None):
122
+ super().__init__(api_key, base_url, timeout, max_retries)
123
+ self._client = httpx.AsyncClient(timeout=self._timeout, transport=transport)
124
+
125
+ async def close(self) -> None:
126
+ await self._client.aclose()
127
+
128
+ async def request(self, method: str, path: str, body: Any = None, query: Optional[Mapping[str, Any]] = None, timeout: Optional[float] = None) -> Any:
129
+ attempt = 0
130
+ content = None if body is None else json.dumps(body).encode("utf-8")
131
+ while True:
132
+ attempt += 1
133
+ try:
134
+ res = await self._client.request(method, self._url(path), params=_clean_query(query), content=content, headers=self._headers, timeout=self._timeout if timeout is None else timeout)
135
+ except (httpx.TransportError, httpx.TimeoutException) as err:
136
+ if attempt <= self._max_retries:
137
+ await asyncio.sleep(0.3 * attempt)
138
+ continue
139
+ raise NixflexConnectionError(f"Could not reach the Nixflex API ({err.__class__.__name__}: {err}). Check connectivity and https://nixflex.com/status") from None
140
+ if res.is_success:
141
+ return None if res.status_code == 204 else res.json()
142
+ request_id, retry_after, err_body = _parse_error(res)
143
+ wait = _retry_plan(res.status_code, method, retry_after, attempt, self._max_retries)
144
+ if wait is not None:
145
+ await asyncio.sleep(wait)
146
+ continue
147
+ raise error_from_response(res.status_code, err_body, request_id, retry_after)
@@ -0,0 +1,78 @@
1
+ """Typed error family. Mirrors the API's error shape exactly:
2
+ { "error": { "type", "code", "message", "doc_url", "details" } }
3
+ Every non-2xx response becomes one of these - catch by class:
4
+ try: ...
5
+ except NixflexRateLimitError as e: sleep(e.retry_after_seconds)
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, Optional
10
+
11
+
12
+ class NixflexError(Exception):
13
+ """Base class. `status` is the HTTP status (0 for network/timeout failures)."""
14
+
15
+ def __init__(self, status: int, body: Optional[Dict[str, Any]], request_id: Optional[str] = None, fallback_message: Optional[str] = None):
16
+ e = (body or {}).get("error") or {}
17
+ super().__init__(e.get("message") or fallback_message or f"Nixflex API error (HTTP {status})")
18
+ self.status: int = status
19
+ self.code: str = e.get("code") or "unknown_error"
20
+ self.type: str = e.get("type") or "error"
21
+ self.doc_url: Optional[str] = e.get("doc_url")
22
+ self.details: Dict[str, Any] = e.get("details") or {}
23
+ self.request_id: Optional[str] = request_id
24
+
25
+ def __repr__(self) -> str: # pragma: no cover
26
+ return f"{self.__class__.__name__}(status={self.status}, code={self.code!r}, message={str(self)!r})"
27
+
28
+
29
+ class NixflexAuthenticationError(NixflexError):
30
+ """401 - missing or invalid API key."""
31
+
32
+
33
+ class NixflexPaymentRequiredError(NixflexError):
34
+ """402 - balance or credit exhausted."""
35
+
36
+
37
+ class NixflexNotFoundError(NixflexError):
38
+ """404 - the resource does not exist (or is not yours)."""
39
+
40
+
41
+ class NixflexInvalidRequestError(NixflexError):
42
+ """400/422 - the request itself is malformed or invalid."""
43
+
44
+
45
+ class NixflexServerError(NixflexError):
46
+ """5xx - something failed on Nixflex's side. GET/DELETE are retried once automatically."""
47
+
48
+
49
+ class NixflexRateLimitError(NixflexError):
50
+ """429 - rate limit hit. `retry_after_seconds` says when to try again."""
51
+
52
+ def __init__(self, status: int, body: Optional[Dict[str, Any]], request_id: Optional[str], retry_after_seconds: int):
53
+ super().__init__(status, body, request_id)
54
+ self.retry_after_seconds: int = retry_after_seconds
55
+
56
+
57
+ class NixflexConnectionError(NixflexError):
58
+ """Network failure / timeout - the request never got an HTTP response."""
59
+
60
+ def __init__(self, message: str):
61
+ super().__init__(0, None, None, message)
62
+
63
+
64
+ def error_from_response(status: int, body: Optional[Dict[str, Any]], request_id: Optional[str], retry_after_seconds: int) -> NixflexError:
65
+ """Map a status + body to the right error class (same table as the Node SDK)."""
66
+ if status == 401:
67
+ return NixflexAuthenticationError(status, body, request_id)
68
+ if status == 402:
69
+ return NixflexPaymentRequiredError(status, body, request_id)
70
+ if status == 404:
71
+ return NixflexNotFoundError(status, body, request_id)
72
+ if status == 429:
73
+ return NixflexRateLimitError(status, body, request_id, retry_after_seconds)
74
+ if status in (400, 422):
75
+ return NixflexInvalidRequestError(status, body, request_id)
76
+ if status >= 500:
77
+ return NixflexServerError(status, body, request_id)
78
+ return NixflexError(status, body, request_id)
File without changes
@@ -0,0 +1,3 @@
1
+ """Resources are written ONCE. Each method returns `self._http.request(...)`, so on the
2
+ sync client that is the result and on the async client it is an awaitable. Same 46
3
+ methods, same names as the Node SDK (snake_case), no duplication."""
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from .._http import enc
4
+
5
+
6
+ class Keys:
7
+ def __init__(self, http):
8
+ self._http = http
9
+
10
+ def rotate(self):
11
+ """New secret. The current one stops working immediately - update every integration."""
12
+ return self._http.request("POST", "/keys/rotate")
13
+
14
+
15
+ class Usage:
16
+ def __init__(self, http):
17
+ self._http = http
18
+
19
+ def get(self):
20
+ return self._http.request("GET", "/usage")
21
+
22
+
23
+ class _ByoBase:
24
+ """Bring your own storage / LLM / TTS. set() is verified by the API with a real probe before saving; get() never returns secrets."""
25
+ _path = ""
26
+
27
+ def __init__(self, http):
28
+ self._http = http
29
+
30
+ def set(self, **params: Any):
31
+ return self._http.request("PUT", self._path, params)
32
+
33
+ def get(self):
34
+ return self._http.request("GET", self._path)
35
+
36
+ def delete(self):
37
+ """Disconnect - calls fall back to Nixflex."""
38
+ return self._http.request("DELETE", self._path)
39
+
40
+
41
+ class Storage(_ByoBase):
42
+ _path = "/account/storage"
43
+
44
+
45
+ class Llm(_ByoBase):
46
+ _path = "/account/llm"
47
+
48
+
49
+ class Tts(_ByoBase):
50
+ _path = "/account/tts"
51
+
52
+
53
+ class Webhooks:
54
+ """Per-number post-call webhook (two slots). Verify deliveries with nixflex.verify_webhook_signature."""
55
+
56
+ def __init__(self, http):
57
+ self._http = http
58
+
59
+ @staticmethod
60
+ def _base(slot: int) -> str:
61
+ return "webhook2" if slot == 2 else "webhook"
62
+
63
+ def set(self, phone_number: str, url: str, slot: int = 1):
64
+ return self._http.request("PUT", f"/integrations/{self._base(slot)}/number/{enc(phone_number)}", {"url": url})
65
+
66
+ def get(self, phone_number: str, slot: int = 1):
67
+ return self._http.request("GET", f"/integrations/{self._base(slot)}/number/{enc(phone_number)}")
68
+
69
+ def delete(self, phone_number: str, slot: int = 1):
70
+ return self._http.request("DELETE", f"/integrations/{self._base(slot)}/number/{enc(phone_number)}")
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, Optional
3
+
4
+
5
+ class Agents:
6
+ def __init__(self, http):
7
+ self._http = http
8
+
9
+ def create(self, **params: Any):
10
+ """Create an agent. Any field the API accepts (name, system_prompt, voice_id, language, incall_sms_enabled, ...)."""
11
+ return self._http.request("POST", "/agents", params)
12
+
13
+ def list(self, limit: Optional[int] = None, offset: Optional[int] = None):
14
+ return self._http.request("GET", "/agents", None, {"limit": limit, "offset": offset})
15
+
16
+ def get(self, agent_id: str):
17
+ return self._http.request("GET", f"/agents/{agent_id}")
18
+
19
+ def update(self, agent_id: str, **params: Any):
20
+ """Only the fields you pass change."""
21
+ return self._http.request("PUT", f"/agents/{agent_id}", params)
22
+
23
+ def delete(self, agent_id: str):
24
+ return self._http.request("DELETE", f"/agents/{agent_id}")
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List
3
+ from .._http import enc
4
+
5
+
6
+ class Callers:
7
+ """Caller context - what the agent knows about a caller on one of your numbers.
8
+ phone_number is YOUR number; caller_number is the customer. Docs: /concepts/caller-context"""
9
+
10
+ def __init__(self, http):
11
+ self._http = http
12
+
13
+ def get(self, phone_number: str, caller_number: str):
14
+ return self._http.request("GET", f"/phone-numbers/{enc(phone_number)}/callers/{enc(caller_number)}")
15
+
16
+ def set(self, phone_number: str, caller_number: str, **fields: Any):
17
+ """name, email, phone, location, reference_id, preference. Omitted fields are kept; pass None to remove one. last_call / open_item are engine-only (400 engine_only_field)."""
18
+ return self._http.request("PUT", f"/phone-numbers/{enc(phone_number)}/callers/{enc(caller_number)}", fields)
19
+
20
+ def delete(self, phone_number: str, caller_number: str):
21
+ """Erases the whole record - yours and the engine's."""
22
+ return self._http.request("DELETE", f"/phone-numbers/{enc(phone_number)}/callers/{enc(caller_number)}")
23
+
24
+ def import_(self, phone_number: str, callers: List[Dict[str, Any]]):
25
+ """Up to 1,000 rows, each {"caller_number": "+44...", ...fields}. Every row is validated before any is written."""
26
+ return self._http.request("POST", f"/phone-numbers/{enc(phone_number)}/callers/import", {"callers": callers})
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional
3
+
4
+
5
+ class Calls:
6
+ def __init__(self, http):
7
+ self._http = http
8
+
9
+ def create(self, agent_id: str, to_number: str, prompt: str, from_number: Optional[str] = None, variables: Optional[Dict[str, str]] = None, **extra: Any):
10
+ """Place an outbound call now. Never retried after a 5xx - a call is never dialled twice."""
11
+ body: Dict[str, Any] = {"agent_id": agent_id, "to_number": to_number, "prompt": prompt, **extra}
12
+ if from_number is not None:
13
+ body["from_number"] = from_number
14
+ if variables is not None:
15
+ body["variables"] = variables
16
+ return self._http.request("POST", "/calls/outbound", body)
17
+
18
+ def list(self, limit: Optional[int] = None, offset: Optional[int] = None, agent_id: Optional[str] = None):
19
+ return self._http.request("GET", "/calls", None, {"limit": limit, "offset": offset, "agent_id": agent_id})
20
+
21
+ def get(self, call_id: str):
22
+ return self._http.request("GET", f"/calls/{call_id}")
23
+
24
+ def delete(self, call_id: str):
25
+ """Removes the record and its recording (GDPR)."""
26
+ return self._http.request("DELETE", f"/calls/{call_id}")
27
+
28
+ def delete_all(self):
29
+ """Every call, recording and SMS record on the account. Cannot be undone."""
30
+ return self._http.request("DELETE", "/calls")
31
+
32
+
33
+ class Campaigns:
34
+ """Voice batch campaigns - many calls under one prompt."""
35
+
36
+ def __init__(self, http):
37
+ self._http = http
38
+
39
+ def create(self, agent_id: str, from_number: str, prompt: str, recipients: List[Dict[str, Any]], **extra: Any):
40
+ """recipients: [{"phone": "+44...", "variables": {...}, "prompt_override": "..."}, ...]. Dials immediately unless schedule_type="schedule"."""
41
+ return self._http.request("POST", "/calls/batch", {"agent_id": agent_id, "from_number": from_number, "prompt": prompt, "recipients": recipients, **extra})
42
+
43
+ def launch(self, campaign_id: str):
44
+ return self._http.request("POST", f"/calls/batch/{campaign_id}/launch")
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Optional
3
+ from .._http import enc
4
+
5
+
6
+ class PhoneNumbers:
7
+ def __init__(self, http):
8
+ self._http = http
9
+
10
+ def import_(self, phone_number: str, agent_id: str, **credentials: Any):
11
+ """Attach a number you own. Twilio: twilio_account_sid + twilio_auth_token. Telnyx: telnyx_api_key + telnyx_connection_id. The carrier is inferred from which you send."""
12
+ return self._http.request("POST", "/phone-numbers", {"phone_number": phone_number, "agent_id": agent_id, **credentials})
13
+
14
+ def list(self, agent_id: Optional[str] = None):
15
+ return self._http.request("GET", "/phone-numbers", None, {"agent_id": agent_id})
16
+
17
+ def update(self, phone_number: str, **params: Any):
18
+ """Per-number settings: custom_prompt, sms_prompt, sms_reply_enabled, web_prompt, dtmf_enabled, record_call, voice_id, speaking_rate, ... Only passed fields change; None clears where the API allows null."""
19
+ return self._http.request("PATCH", f"/phone-numbers/{enc(phone_number)}", params)
20
+
21
+ def delete(self, phone_number: str):
22
+ """Disconnects from Nixflex. Your carrier keeps the number and keeps billing it."""
23
+ return self._http.request("DELETE", f"/phone-numbers/{enc(phone_number)}")
24
+
25
+ def set_monitor(self, phone_number: str, enabled: bool):
26
+ """Live call monitoring. On bills that number's inbound calls at the premium rate."""
27
+ return self._http.request("PUT", f"/integrations/monitor/number/{enc(phone_number)}", {"enabled": enabled})
28
+
29
+ def get_monitor(self, phone_number: str):
30
+ return self._http.request("GET", f"/integrations/monitor/number/{enc(phone_number)}")
31
+
32
+ def set_web_calls(self, phone_number: str, enabled: bool):
33
+ return self._http.request("PUT", f"/integrations/web-calls/number/{enc(phone_number)}", {"enabled": enabled})
34
+
35
+ def get_web_calls(self, phone_number: str):
36
+ return self._http.request("GET", f"/integrations/web-calls/number/{enc(phone_number)}")
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional
3
+
4
+
5
+ class SmsCampaigns:
6
+ def __init__(self, http):
7
+ self._http = http
8
+
9
+ def create(self, agent_id: str, from_number: str, message: str, recipients: List[Dict[str, Any]], **extra: Any):
10
+ """recipients: [{"phone": "+44...", "variables": {...}}, ...]. from_number may be on either carrier."""
11
+ return self._http.request("POST", "/sms/campaigns", {"agent_id": agent_id, "from_number": from_number, "message": message, "recipients": recipients, **extra})
12
+
13
+ def launch(self, campaign_id: str):
14
+ return self._http.request("POST", f"/sms/campaigns/{campaign_id}/launch")
15
+
16
+ def list(self, status: Optional[str] = None, limit: Optional[int] = None):
17
+ return self._http.request("GET", "/sms/campaigns", None, {"status": status, "limit": limit})
18
+
19
+ def get(self, campaign_id: str):
20
+ return self._http.request("GET", f"/sms/campaigns/{campaign_id}")
21
+
22
+ def delete(self, campaign_id: str):
23
+ """Pending recipients are not messaged. Sent messages cannot be recalled."""
24
+ return self._http.request("DELETE", f"/sms/campaigns/{campaign_id}")
25
+
26
+
27
+ class Sms:
28
+ def __init__(self, http):
29
+ self._http = http
30
+ self.campaigns = SmsCampaigns(http)
31
+
32
+ def send(self, agent_id: str, from_number: str, to: str, message: str, **extra: Any):
33
+ """One text from one of your numbers. Under 600 characters delivers reliably."""
34
+ return self._http.request("POST", "/sms", {"agent_id": agent_id, "from_number": from_number, "to": to, "message": message, **extra})
@@ -0,0 +1,51 @@
1
+ """Verify Nixflex webhook signatures.
2
+
3
+ The engine signs every delivery:
4
+ header X-Nixflex-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>
5
+ payload "<timestamp>.<raw_body>" secret: your key_secret
6
+
7
+ Use the RAW request body BYTES exactly as received - a re-serialised JSON body may
8
+ differ from what was signed and will fail verification. Never raises.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import hmac
14
+ import re
15
+ import time
16
+ from typing import Optional, Union
17
+
18
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
19
+
20
+
21
+ def verify_webhook_signature(
22
+ raw_body: Union[bytes, str],
23
+ signature_header: Optional[str],
24
+ key_secret: str,
25
+ tolerance_seconds: int = 300,
26
+ now: Optional[int] = None,
27
+ ) -> bool:
28
+ """True only if the signature is authentic AND fresh (within `tolerance_seconds`)."""
29
+ try:
30
+ if not signature_header or not key_secret:
31
+ return False
32
+ parts = {}
33
+ for seg in signature_header.split(","):
34
+ i = seg.find("=")
35
+ if i > 0:
36
+ parts[seg[:i].strip()] = seg[i + 1:].strip()
37
+ try:
38
+ t = int(parts.get("t", ""))
39
+ except ValueError:
40
+ return False
41
+ v1 = parts.get("v1", "")
42
+ if not _HEX64.match(v1):
43
+ return False
44
+ current = int(time.time()) if now is None else now
45
+ if abs(current - t) > tolerance_seconds:
46
+ return False # stale or future-dated = replay risk
47
+ body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
48
+ expected = hmac.new(key_secret.encode("utf-8"), f"{t}.".encode("utf-8") + body, hashlib.sha256).hexdigest()
49
+ return hmac.compare_digest(expected, v1) # constant-time
50
+ except Exception:
51
+ return False
@@ -0,0 +1,187 @@
1
+ """Behaviour tests against a mock transport - no network. Same cases as the Node SDK."""
2
+ import json
3
+ import time
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from nixflex import (
9
+ AsyncNixflex,
10
+ Nixflex,
11
+ NixflexAuthenticationError,
12
+ NixflexConnectionError,
13
+ NixflexInvalidRequestError,
14
+ NixflexNotFoundError,
15
+ NixflexPaymentRequiredError,
16
+ NixflexRateLimitError,
17
+ NixflexServerError,
18
+ )
19
+
20
+ KEY = "nxf_test:nxfs_test"
21
+ ERR = lambda code, msg="boom", typ="invalid_request": {"error": {"type": typ, "code": code, "message": msg, "doc_url": "https://docs.nixflex.com/errors/" + code, "details": {}}}
22
+
23
+
24
+ def client_with(handler, **kw):
25
+ return Nixflex(api_key=KEY, transport=httpx.MockTransport(handler), **kw)
26
+
27
+
28
+ def test_requires_full_key_pair():
29
+ with pytest.raises(ValueError):
30
+ Nixflex(api_key="nxf_only_the_id")
31
+
32
+
33
+ def test_auth_header_user_agent_and_path():
34
+ seen = {}
35
+
36
+ def handler(req: httpx.Request):
37
+ seen["auth"] = req.headers["authorization"]
38
+ seen["ua"] = req.headers["user-agent"]
39
+ seen["url"] = str(req.url)
40
+ return httpx.Response(200, json=[{"agent_id": "a1"}])
41
+
42
+ with client_with(handler) as c:
43
+ assert c.agents.list(limit=5) == [{"agent_id": "a1"}]
44
+ assert seen["auth"] == "Bearer " + KEY
45
+ assert seen["ua"].startswith("nixflex-python/")
46
+ assert seen["url"] == "https://api.nixflex.com/v1/agents?limit=5"
47
+
48
+
49
+ def test_phone_numbers_are_url_encoded():
50
+ seen = {}
51
+
52
+ def handler(req):
53
+ seen["path"] = req.url.raw_path.decode()
54
+ return httpx.Response(200, json={"caller": {}})
55
+
56
+ with client_with(handler) as c:
57
+ c.callers.get("+447450307843", "+447453573770")
58
+ assert seen["path"] == "/v1/phone-numbers/%2B447450307843/callers/%2B447453573770"
59
+
60
+
61
+ def test_error_classes_by_status():
62
+ cases = [(401, NixflexAuthenticationError), (402, NixflexPaymentRequiredError), (404, NixflexNotFoundError), (400, NixflexInvalidRequestError), (422, NixflexInvalidRequestError), (503, NixflexServerError)]
63
+ for status, cls in cases:
64
+ with client_with(lambda req, s=status: httpx.Response(s, json=ERR("x_code", "why"), headers={"x-railway-request-id": "req-1"}), max_retries=0) as c:
65
+ with pytest.raises(cls) as ei:
66
+ c.agents.get("a1")
67
+ e = ei.value
68
+ assert e.status == status and e.code == "x_code" and str(e) == "why" and e.request_id == "req-1"
69
+ assert e.doc_url.endswith("/x_code")
70
+
71
+
72
+ def test_429_retries_once_honouring_retry_after():
73
+ calls = []
74
+
75
+ def handler(req):
76
+ calls.append(time.monotonic())
77
+ if len(calls) == 1:
78
+ return httpx.Response(429, json=ERR("rate_limit_exceeded", typ="rate_limit"), headers={"retry-after": "1"})
79
+ return httpx.Response(200, json={"ok": True})
80
+
81
+ with client_with(handler) as c:
82
+ assert c.agents.get("a1") == {"ok": True}
83
+ assert len(calls) == 2 and (calls[1] - calls[0]) >= 0.9
84
+
85
+
86
+ def test_429_exhausted_raises_with_retry_after_seconds():
87
+ with client_with(lambda req: httpx.Response(429, json=ERR("rate_limit_exceeded", typ="rate_limit"), headers={"retry-after": "7"}), max_retries=0) as c:
88
+ with pytest.raises(NixflexRateLimitError) as ei:
89
+ c.usage.get()
90
+ assert ei.value.retry_after_seconds == 7
91
+
92
+
93
+ def test_5xx_retries_GET_but_never_POST():
94
+ n = {"get": 0, "post": 0}
95
+
96
+ def handler(req):
97
+ n[req.method.lower()] += 1
98
+ return httpx.Response(502, json=ERR("upstream", typ="server_error"))
99
+
100
+ with client_with(handler) as c:
101
+ with pytest.raises(NixflexServerError):
102
+ c.agents.get("a1")
103
+ with pytest.raises(NixflexServerError):
104
+ c.calls.create(agent_id="a1", to_number="+447700900123", prompt="hi")
105
+ assert n["get"] == 2, "GET must retry once on 5xx"
106
+ assert n["post"] == 1, "POST must NEVER be retried after a 5xx - a call could be dialled twice"
107
+
108
+
109
+ def test_network_failure_retries_then_connection_error():
110
+ n = {"c": 0}
111
+
112
+ def handler(req):
113
+ n["c"] += 1
114
+ raise httpx.ConnectError("refused")
115
+
116
+ with client_with(handler) as c:
117
+ with pytest.raises(NixflexConnectionError) as ei:
118
+ c.usage.get()
119
+ assert n["c"] == 2 and ei.value.status == 0
120
+
121
+
122
+ def test_204_returns_none_and_none_query_values_are_dropped():
123
+ seen = {}
124
+
125
+ def handler(req):
126
+ seen["q"] = req.url.query.decode()
127
+ return httpx.Response(204)
128
+
129
+ with client_with(handler) as c:
130
+ assert c.agents.list() is None
131
+ assert seen["q"] == ""
132
+
133
+
134
+ def test_import_and_set_bodies():
135
+ seen = {}
136
+
137
+ def handler(req):
138
+ seen[req.url.raw_path.decode()] = json.loads(req.content)
139
+ return httpx.Response(200, json={})
140
+
141
+ with client_with(handler) as c:
142
+ c.callers.set("+447450307843", "+447453573770", name="Sam Carter", email=None)
143
+ c.callers.import_("+447450307843", [{"caller_number": "+447700900002", "name": "Priya"}])
144
+ c.webhooks.set("+447450307843", "https://x.example/hook", slot=2)
145
+ assert seen["/v1/phone-numbers/%2B447450307843/callers/%2B447453573770"] == {"name": "Sam Carter", "email": None}
146
+ assert seen["/v1/phone-numbers/%2B447450307843/callers/import"] == {"callers": [{"caller_number": "+447700900002", "name": "Priya"}]}
147
+ assert "/v1/integrations/webhook2/number/%2B447450307843" in seen
148
+
149
+
150
+ async def test_async_client_same_behaviour():
151
+ n = {"c": 0}
152
+
153
+ def handler(req):
154
+ n["c"] += 1
155
+ if n["c"] == 1:
156
+ return httpx.Response(429, json=ERR("rate_limit_exceeded", typ="rate_limit"), headers={"retry-after": "0"})
157
+ return httpx.Response(200, json={"plan": "pay_as_you_go"})
158
+
159
+ async with AsyncNixflex(api_key=KEY, transport=httpx.MockTransport(handler)) as c:
160
+ assert await c.usage.get() == {"plan": "pay_as_you_go"}
161
+ assert n["c"] == 2
162
+
163
+
164
+ def test_every_resource_and_method_exists():
165
+ """The Python surface must equal the Node SDK's API methods (snake_case; import -> import_). 44 API methods + verify_webhook_signature as a module function (Node has webhooks.verify)."""
166
+ expected = {
167
+ "agents": ["create", "list", "get", "update", "delete"],
168
+ "calls": ["create", "list", "get", "delete", "delete_all"],
169
+ "campaigns": ["create", "launch"],
170
+ "phone_numbers": ["import_", "list", "update", "delete", "set_monitor", "get_monitor", "set_web_calls", "get_web_calls"],
171
+ "callers": ["get", "set", "delete", "import_"],
172
+ "sms": ["send"], "sms.campaigns": ["create", "launch", "list", "get", "delete"],
173
+ "keys": ["rotate"], "usage": ["get"], "webhooks": ["set", "get", "delete"],
174
+ "storage": ["set", "get", "delete"], "llm": ["set", "get", "delete"], "tts": ["set", "get", "delete"],
175
+ }
176
+ c = Nixflex(api_key=KEY, transport=httpx.MockTransport(lambda r: httpx.Response(200, json={})))
177
+ total = 0
178
+ for res, methods in expected.items():
179
+ obj = c
180
+ for part in res.split("."):
181
+ obj = getattr(obj, part)
182
+ for m in methods:
183
+ assert callable(getattr(obj, m)), f"{res}.{m} missing"
184
+ total += 1
185
+ assert total == 44
186
+ from nixflex import verify_webhook_signature
187
+ assert callable(verify_webhook_signature)
@@ -0,0 +1,40 @@
1
+ import hashlib
2
+ import hmac
3
+
4
+ from nixflex import verify_webhook_signature
5
+
6
+ SECRET = "nxfs_test_secret"
7
+ BODY = b'{"event":"call.ended","call_id":"CA1"}'
8
+ T = 1_788_600_000
9
+
10
+
11
+ def sig(t=T, body=BODY, secret=SECRET):
12
+ return "t=%d,v1=%s" % (t, hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest())
13
+
14
+
15
+ def test_golden_vector_matches_node_scheme():
16
+ # Same inputs as the Node SDK's golden vector: HMAC-SHA256 of "<ts>.<raw_body>".
17
+ assert verify_webhook_signature(BODY, sig(), SECRET, now=T)
18
+
19
+
20
+ def test_str_body_and_bytes_body_agree():
21
+ assert verify_webhook_signature(BODY.decode(), sig(), SECRET, now=T)
22
+
23
+
24
+ def test_rejects_wrong_secret_tampered_body_and_bad_header():
25
+ assert not verify_webhook_signature(BODY, sig(), "other", now=T)
26
+ assert not verify_webhook_signature(BODY + b" ", sig(), SECRET, now=T)
27
+ assert not verify_webhook_signature(BODY, "garbage", SECRET, now=T)
28
+ assert not verify_webhook_signature(BODY, None, SECRET, now=T)
29
+ assert not verify_webhook_signature(BODY, sig(), "", now=T)
30
+
31
+
32
+ def test_replay_window():
33
+ assert verify_webhook_signature(BODY, sig(), SECRET, now=T + 299)
34
+ assert not verify_webhook_signature(BODY, sig(), SECRET, now=T + 301)
35
+ assert not verify_webhook_signature(BODY, sig(), SECRET, now=T - 301)
36
+ assert verify_webhook_signature(BODY, sig(), SECRET, now=T + 1000, tolerance_seconds=2000)
37
+
38
+
39
+ def test_never_raises():
40
+ assert verify_webhook_signature(None, sig(), SECRET, now=T) is False # type: ignore[arg-type]