lessotp-sdk 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,45 @@
1
+ # Dependencies
2
+ node_modules/
3
+ vendor/
4
+ .venv/
5
+ venv/
6
+ .env/
7
+
8
+ # Build outputs
9
+ dist/
10
+ build/
11
+ coverage/
12
+ *.egg-info/
13
+
14
+ # Python
15
+ __pycache__/
16
+ *.py[cod]
17
+ .pytest_cache/
18
+ .ruff_cache/
19
+ .mypy_cache/
20
+
21
+ # JavaScript / package managers
22
+ .bun/
23
+ .npm/
24
+ .pnpm-store/
25
+ .yarn/
26
+ *.tsbuildinfo
27
+
28
+ # Logs
29
+ *.log
30
+ npm-debug.log*
31
+ yarn-debug.log*
32
+ yarn-error.log*
33
+ pnpm-debug.log*
34
+
35
+ # OS / editors
36
+ .DS_Store
37
+ Thumbs.db
38
+ .idea/
39
+ .vscode/
40
+
41
+ # Local env
42
+ .env
43
+ .env.*
44
+ !.env.example
45
+ !.env.*.example
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: lessotp-sdk
3
+ Version: 0.1.0
4
+ Summary: LessOTP Inbound WhatsApp Authentication client SDK for Python.
5
+ Project-URL: Homepage, https://lessotp.com
6
+ Project-URL: Repository, https://github.com/lessotp/sdk
7
+ Project-URL: Issues, https://github.com/lessotp/sdk/issues
8
+ Author: LessOTP
9
+ License-Expression: MIT
10
+ Keywords: authentication,lessotp,passwordless,verification,whatsapp
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Requires-Python: >=3.8
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=8.0; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # LessOTP Python SDK
27
+
28
+ Client for the **LessOTP Inbound WhatsApp Authentication API** (Python 3.8+).
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install lessotp-sdk
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ import os
40
+ from lessotp_sdk import LessOTPClient, parse_verified_webhook
41
+
42
+ # production (default)
43
+ client = LessOTPClient(api_key=os.environ["LESSOTP_API_KEY"])
44
+
45
+ # staging
46
+ staging = LessOTPClient(
47
+ api_key=os.environ["LESSOTP_STAGING_API_KEY"],
48
+ environment="staging",
49
+ )
50
+
51
+ # strict
52
+ strict = client.auth_request("6281234567890")
53
+
54
+ # frictionless
55
+ frictionless = client.auth_request()
56
+
57
+ # per-call override
58
+ one_off = client.auth_request("6281234567890", environment="staging")
59
+
60
+ # webhook verification
61
+ event = parse_verified_webhook(
62
+ request.get_data(as_text=True),
63
+ request.headers.get("X-Signature"),
64
+ os.environ["LESSOTP_WEBHOOK_SECRET"],
65
+ )
66
+ if event is None:
67
+ return "bad signature", 403
68
+ print(event.request_id, event.phone_number)
69
+ ```
70
+
71
+ ## API
72
+
73
+ ### `LessOTPClient(api_key, environment='production', base_url='https://api.lessotp.com', timeout_seconds=10)`
74
+
75
+ | Option | Default | Description |
76
+ | --- | --- | --- |
77
+ | `api_key` | required | App API key. |
78
+ | `environment` | `"production"` | `"production"` or `"staging"`. |
79
+ | `base_url` | `https://api.lessotp.com` | API host. |
80
+ | `timeout_seconds` | `10` | HTTP timeout. |
81
+
82
+ ### `client.auth_request(phone_number=None, environment=None) -> AuthRequestResult`
83
+
84
+ Calls the endpoint selected by `environment`. The per-call `environment` overrides the client environment.
85
+
86
+ ### `verify_webhook_signature(raw_body, signature_header, secret) -> bool`
87
+
88
+ Constant-time HMAC-SHA256 verification. Accepts raw hex and `sha256=` prefixed values.
89
+
90
+ ### `parse_verified_webhook(raw_body, signature_header, secret) -> VerificationSuccess | None`
91
+
92
+ Returns the parsed payload if the signature is valid; `None` otherwise.
93
+
94
+ ## Errors
95
+
96
+ Raises `LessOTPError` on transport, auth, or payload problems.
97
+
98
+ ## Tests
99
+
100
+ ```bash
101
+ python -m pip install -e ".[test]"
102
+ python -m pytest
103
+ ```
@@ -0,0 +1,78 @@
1
+ # LessOTP Python SDK
2
+
3
+ Client for the **LessOTP Inbound WhatsApp Authentication API** (Python 3.8+).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install lessotp-sdk
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import os
15
+ from lessotp_sdk import LessOTPClient, parse_verified_webhook
16
+
17
+ # production (default)
18
+ client = LessOTPClient(api_key=os.environ["LESSOTP_API_KEY"])
19
+
20
+ # staging
21
+ staging = LessOTPClient(
22
+ api_key=os.environ["LESSOTP_STAGING_API_KEY"],
23
+ environment="staging",
24
+ )
25
+
26
+ # strict
27
+ strict = client.auth_request("6281234567890")
28
+
29
+ # frictionless
30
+ frictionless = client.auth_request()
31
+
32
+ # per-call override
33
+ one_off = client.auth_request("6281234567890", environment="staging")
34
+
35
+ # webhook verification
36
+ event = parse_verified_webhook(
37
+ request.get_data(as_text=True),
38
+ request.headers.get("X-Signature"),
39
+ os.environ["LESSOTP_WEBHOOK_SECRET"],
40
+ )
41
+ if event is None:
42
+ return "bad signature", 403
43
+ print(event.request_id, event.phone_number)
44
+ ```
45
+
46
+ ## API
47
+
48
+ ### `LessOTPClient(api_key, environment='production', base_url='https://api.lessotp.com', timeout_seconds=10)`
49
+
50
+ | Option | Default | Description |
51
+ | --- | --- | --- |
52
+ | `api_key` | required | App API key. |
53
+ | `environment` | `"production"` | `"production"` or `"staging"`. |
54
+ | `base_url` | `https://api.lessotp.com` | API host. |
55
+ | `timeout_seconds` | `10` | HTTP timeout. |
56
+
57
+ ### `client.auth_request(phone_number=None, environment=None) -> AuthRequestResult`
58
+
59
+ Calls the endpoint selected by `environment`. The per-call `environment` overrides the client environment.
60
+
61
+ ### `verify_webhook_signature(raw_body, signature_header, secret) -> bool`
62
+
63
+ Constant-time HMAC-SHA256 verification. Accepts raw hex and `sha256=` prefixed values.
64
+
65
+ ### `parse_verified_webhook(raw_body, signature_header, secret) -> VerificationSuccess | None`
66
+
67
+ Returns the parsed payload if the signature is valid; `None` otherwise.
68
+
69
+ ## Errors
70
+
71
+ Raises `LessOTPError` on transport, auth, or payload problems.
72
+
73
+ ## Tests
74
+
75
+ ```bash
76
+ python -m pip install -e ".[test]"
77
+ python -m pytest
78
+ ```
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lessotp-sdk"
7
+ version = "0.1.0"
8
+ description = "LessOTP Inbound WhatsApp Authentication client SDK for Python."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ authors = [{ name = "LessOTP" }]
13
+ keywords = ["lessotp", "whatsapp", "authentication", "verification", "passwordless"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.8",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Internet :: WWW/HTTP",
25
+ ]
26
+
27
+ dependencies = []
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=8.0"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://lessotp.com"
34
+ Repository = "https://github.com/lessotp/sdk"
35
+ Issues = "https://github.com/lessotp/sdk/issues"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/lessotp_sdk"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ pythonpath = ["src"]
@@ -0,0 +1,216 @@
1
+ """LessOTP Inbound WhatsApp Authentication API — client SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ from typing import Any, Dict, Optional, Union
10
+ from urllib import error as urlerror
11
+ from urllib import request as urlrequest
12
+
13
+ DEFAULT_BASE_URL = "https://api.lessotp.com"
14
+ DEFAULT_TIMEOUT_SECONDS = 10
15
+ DEFAULT_ENVIRONMENT = "production"
16
+ VALID_ENVIRONMENTS = ("production", "staging")
17
+ RawBody = Union[str, bytes]
18
+
19
+
20
+ class LessOTPError(RuntimeError):
21
+ """Surface error type for SDK consumers."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class AuthRequestResult:
26
+ """Normalized response from ``POST /api/v1/auth/request``."""
27
+
28
+ request_id: str
29
+ unique_code: str
30
+ wa_link: str
31
+ expires_in: int
32
+ mode: str
33
+
34
+ @classmethod
35
+ def from_response(cls, payload: Dict[str, Any]) -> "AuthRequestResult":
36
+ data = payload.get("data")
37
+ if not isinstance(data, dict):
38
+ raise LessOTPError("LessOTP response missing 'data' object")
39
+ required = ["request_id", "unique_code", "wa_link", "expires_in", "mode"]
40
+ for key in required:
41
+ if key not in data:
42
+ raise LessOTPError("LessOTP response missing '%s'" % key)
43
+ mode = str(data["mode"])
44
+ if mode not in {"strict", "frictionless"}:
45
+ raise LessOTPError(
46
+ "LessOTP response mode must be 'strict' or 'frictionless', got '%s'" % mode
47
+ )
48
+ return cls(
49
+ request_id=str(data["request_id"]),
50
+ unique_code=str(data["unique_code"]),
51
+ wa_link=str(data["wa_link"]),
52
+ expires_in=int(data["expires_in"]),
53
+ mode=mode,
54
+ )
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class VerificationSuccess:
59
+ """Canonical representation of a ``verification.success`` webhook payload."""
60
+
61
+ event: str
62
+ request_id: str
63
+ phone_number: str
64
+ timestamp: Optional[str] = None
65
+
66
+ @classmethod
67
+ def from_payload(cls, payload: Dict[str, Any]) -> "VerificationSuccess":
68
+ event = str(payload.get("event", ""))
69
+ if event != "verification.success":
70
+ raise LessOTPError("unexpected webhook event '%s'" % event)
71
+ request_id = payload.get("request_id")
72
+ phone_number = payload.get("phone_number")
73
+ if not isinstance(request_id, str) or not isinstance(phone_number, str):
74
+ raise LessOTPError("webhook payload missing request_id or phone_number")
75
+ timestamp = payload.get("timestamp")
76
+ return cls(
77
+ event=event,
78
+ request_id=request_id,
79
+ phone_number=phone_number,
80
+ timestamp=timestamp if isinstance(timestamp, str) else None,
81
+ )
82
+
83
+
84
+ class LessOTPClient:
85
+ """Stateless HTTP client for the LessOTP API."""
86
+
87
+ def __init__(
88
+ self,
89
+ api_key: str,
90
+ environment: str = DEFAULT_ENVIRONMENT,
91
+ base_url: str = DEFAULT_BASE_URL,
92
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
93
+ ) -> None:
94
+ if not api_key:
95
+ raise LessOTPError("api_key is required")
96
+ self._api_key = api_key
97
+ self._environment = _resolve_environment(environment)
98
+ self._base_url = base_url.rstrip("/")
99
+ self._timeout_seconds = timeout_seconds
100
+
101
+ def auth_request(
102
+ self,
103
+ phone_number: Optional[str] = None,
104
+ environment: Optional[str] = None,
105
+ ) -> AuthRequestResult:
106
+ """Create a verification request.
107
+
108
+ Endpoint is selected from the client environment. Pass ``environment``
109
+ to override for this call.
110
+ """
111
+
112
+ env = _resolve_environment(environment if environment is not None else self._environment)
113
+ return self._auth_request(_endpoint_for(env), phone_number)
114
+
115
+ def _auth_request(self, path: str, phone_number: Optional[str]) -> AuthRequestResult:
116
+ body = {} if phone_number is None else {"phone_number": phone_number}
117
+ raw_body = json.dumps(body, separators=(",", ":")).encode("utf-8")
118
+ req = urlrequest.Request(
119
+ self._base_url + path,
120
+ data=raw_body,
121
+ method="POST",
122
+ headers={
123
+ "Authorization": "Bearer %s" % self._api_key,
124
+ "Content-Type": "application/json",
125
+ "Accept": "application/json",
126
+ "User-Agent": "lessotp-sdk-python/0.1.0",
127
+ },
128
+ )
129
+ try:
130
+ with urlrequest.urlopen(req, timeout=self._timeout_seconds) as response:
131
+ status = getattr(response, "status", response.getcode())
132
+ raw = response.read()
133
+ except urlerror.HTTPError as exc:
134
+ raw = exc.read().decode("utf-8", errors="replace")
135
+ raise LessOTPError("LessOTP auth_request failed: %s: %s" % (exc.code, raw)) from exc
136
+ except urlerror.URLError as exc:
137
+ raise LessOTPError("LessOTP auth_request transport error: %s" % exc.reason) from exc
138
+
139
+ if status < 200 or status >= 300:
140
+ raise LessOTPError(
141
+ "LessOTP auth_request failed: %s: %s"
142
+ % (status, raw.decode("utf-8", errors="replace"))
143
+ )
144
+
145
+ try:
146
+ payload = json.loads(raw.decode("utf-8"))
147
+ except json.JSONDecodeError as exc:
148
+ raise LessOTPError("LessOTP response was not valid JSON") from exc
149
+ if not isinstance(payload, dict) or payload.get("status") != "success":
150
+ raise LessOTPError("LessOTP response missing 'status: success'")
151
+ return AuthRequestResult.from_response(payload)
152
+
153
+
154
+ def _resolve_environment(value: Optional[str]) -> str:
155
+ if value is None or value == "":
156
+ return "production"
157
+ if value not in VALID_ENVIRONMENTS:
158
+ raise LessOTPError(
159
+ "LessOTP environment must be 'production' or 'staging', got '%s'" % value
160
+ )
161
+ return value
162
+
163
+
164
+ def _endpoint_for(environment: str) -> str:
165
+ if environment == "staging":
166
+ return "/api/v1/staging/auth/request"
167
+ return "/api/v1/auth/request"
168
+
169
+
170
+ def verify_webhook_signature(
171
+ raw_body: RawBody,
172
+ signature_header: Optional[str],
173
+ secret: str,
174
+ ) -> bool:
175
+ """Return true when ``signature_header`` is a valid HMAC-SHA256."""
176
+
177
+ if not signature_header or not secret:
178
+ return False
179
+ stripped = signature_header.strip()
180
+ if stripped.lower().startswith("sha256="):
181
+ stripped = stripped[7:]
182
+ if len(stripped) == 0 or len(stripped) % 2 != 0:
183
+ return False
184
+ try:
185
+ provided = bytes.fromhex(stripped)
186
+ except ValueError:
187
+ return False
188
+ body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
189
+ expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).digest()
190
+ return hmac.compare_digest(expected, provided)
191
+
192
+
193
+ def parse_verified_webhook(
194
+ raw_body: RawBody,
195
+ signature_header: Optional[str],
196
+ secret: str,
197
+ ) -> Optional[VerificationSuccess]:
198
+ """Verify and parse a LessOTP ``verification.success`` webhook payload."""
199
+
200
+ if not verify_webhook_signature(raw_body, signature_header, secret):
201
+ return None
202
+ text = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
203
+ payload = json.loads(text)
204
+ if not isinstance(payload, dict):
205
+ raise LessOTPError("webhook payload is not an object")
206
+ return VerificationSuccess.from_payload(payload)
207
+
208
+
209
+ __all__ = [
210
+ "AuthRequestResult",
211
+ "LessOTPClient",
212
+ "LessOTPError",
213
+ "VerificationSuccess",
214
+ "parse_verified_webhook",
215
+ "verify_webhook_signature",
216
+ ]
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ import threading
8
+ from urllib import error as urlerror
9
+
10
+ import pytest
11
+
12
+ from lessotp_sdk import (
13
+ DEFAULT_ENVIRONMENT,
14
+ LessOTPClient,
15
+ LessOTPError,
16
+ parse_verified_webhook,
17
+ verify_webhook_signature,
18
+ )
19
+
20
+
21
+ class _Handler(BaseHTTPRequestHandler):
22
+ status = 200
23
+ response = {
24
+ "status": "success",
25
+ "data": {
26
+ "request_id": "req_abc",
27
+ "unique_code": "A7X92",
28
+ "wa_link": "https://wa.me/628999999999?text=%2FLOGIN%20A7X92",
29
+ "expires_in": 180,
30
+ "mode": "strict",
31
+ },
32
+ }
33
+ requests: list = []
34
+
35
+ def do_POST(self) -> None: # noqa: N802
36
+ length = int(self.headers.get("content-length", "0"))
37
+ body = self.rfile.read(length).decode("utf-8")
38
+ self.__class__.requests.append(
39
+ {
40
+ "path": self.path,
41
+ "authorization": self.headers.get("authorization", ""),
42
+ "body": body,
43
+ }
44
+ )
45
+ raw = json.dumps(self.__class__.response).encode("utf-8")
46
+ self.send_response(self.__class__.status)
47
+ self.send_header("content-type", "application/json")
48
+ self.send_header("content-length", str(len(raw)))
49
+ self.end_headers()
50
+ self.wfile.write(raw)
51
+
52
+ def log_message(self, format: str, *args: object) -> None:
53
+ return
54
+
55
+
56
+ @pytest.fixture
57
+ def server():
58
+ _Handler.status = 200
59
+ _Handler.response = {
60
+ "status": "success",
61
+ "data": {
62
+ "request_id": "req_abc",
63
+ "unique_code": "A7X92",
64
+ "wa_link": "https://wa.me/628999999999?text=%2FLOGIN%20A7X92",
65
+ "expires_in": 180,
66
+ "mode": "strict",
67
+ },
68
+ }
69
+ _Handler.requests = []
70
+ srv = HTTPServer(("127.0.0.1", 0), _Handler)
71
+ thread = threading.Thread(target=srv.serve_forever, daemon=True)
72
+ thread.start()
73
+ try:
74
+ yield srv
75
+ finally:
76
+ srv.shutdown()
77
+ thread.join(timeout=2)
78
+
79
+
80
+ def _url(server: HTTPServer) -> str:
81
+ return "http://127.0.0.1:%d" % server.server_port
82
+
83
+
84
+ def test_default_environment_is_production(server: HTTPServer) -> None:
85
+ client = LessOTPClient("key_test", base_url=_url(server))
86
+ assert client._environment == DEFAULT_ENVIRONMENT == "production"
87
+
88
+ client.auth_request("6281234567890")
89
+ assert _Handler.requests[0]["path"] == "/api/v1/auth/request"
90
+
91
+
92
+ def test_constructor_environment_routes_to_staging(server: HTTPServer) -> None:
93
+ client = LessOTPClient("key_test", environment="staging", base_url=_url(server))
94
+ _Handler.response["data"]["request_id"] = "req_stage"
95
+
96
+ result = client.auth_request("6281234567890")
97
+ assert result.request_id == "req_stage"
98
+ assert _Handler.requests[0]["path"] == "/api/v1/staging/auth/request"
99
+
100
+
101
+ def test_per_call_environment_override_beats_constructor(server: HTTPServer) -> None:
102
+ client = LessOTPClient("key_test", base_url=_url(server))
103
+ _Handler.response["data"]["request_id"] = "req_stage"
104
+
105
+ client.auth_request("6281234567890", environment="staging")
106
+ assert _Handler.requests[0]["path"] == "/api/v1/staging/auth/request"
107
+
108
+
109
+ def test_auth_request_strict_posts_to_production_endpoint(server: HTTPServer) -> None:
110
+ client = LessOTPClient("key_test", base_url=_url(server))
111
+ result = client.auth_request("6281234567890")
112
+
113
+ assert result.request_id == "req_abc"
114
+ assert result.mode == "strict"
115
+ assert _Handler.requests[0]["path"] == "/api/v1/auth/request"
116
+ assert _Handler.requests[0]["authorization"] == "Bearer key_test"
117
+ assert json.loads(_Handler.requests[0]["body"]) == {"phone_number": "6281234567890"}
118
+
119
+
120
+ def test_auth_request_frictionless_sends_empty_object(server: HTTPServer) -> None:
121
+ _Handler.response["data"]["mode"] = "frictionless"
122
+ client = LessOTPClient("key_test", base_url=_url(server))
123
+ result = client.auth_request()
124
+ assert result.mode == "frictionless"
125
+ assert json.loads(_Handler.requests[0]["body"]) == {}
126
+
127
+
128
+ def test_non_2xx_raises_lessotp_error(server: HTTPServer) -> None:
129
+ _Handler.status = 401
130
+ _Handler.response = {"error": "invalid_api_key"}
131
+ client = LessOTPClient("bad", base_url=_url(server))
132
+ with pytest.raises(LessOTPError, match="401"):
133
+ client.auth_request()
134
+
135
+
136
+ def test_unknown_environment_raises(server: HTTPServer) -> None:
137
+ with pytest.raises(LessOTPError, match="environment must be"):
138
+ LessOTPClient("k", environment="qa", base_url=_url(server))
139
+
140
+
141
+ def test_verify_webhook_signature_accepts_hex_and_prefix() -> None:
142
+ body = json.dumps({"event": "verification.success", "request_id": "r1", "phone_number": "628"})
143
+ secret = "whsec_test"
144
+ sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
145
+
146
+ assert verify_webhook_signature(body, sig, secret) is True
147
+ assert verify_webhook_signature(body, "sha256=" + sig, secret) is True
148
+
149
+
150
+ def test_verify_webhook_signature_rejects_bad_inputs() -> None:
151
+ assert verify_webhook_signature("{}", None, "secret") is False
152
+ assert verify_webhook_signature("{}", "abc", "secret") is False
153
+ assert verify_webhook_signature("{}", "a" * 64, "secret") is False
154
+ assert verify_webhook_signature("{}", "a" * 64, "") is False
155
+
156
+
157
+ def test_parse_verified_webhook_returns_none_on_bad_signature() -> None:
158
+ body = json.dumps({"event": "verification.success", "request_id": "r1", "phone_number": "628"})
159
+ assert parse_verified_webhook(body, None, "secret") is None
160
+
161
+
162
+ def test_parse_verified_webhook_parses_valid_payload() -> None:
163
+ payload = {
164
+ "event": "verification.success",
165
+ "request_id": "r1",
166
+ "phone_number": "6281234567890",
167
+ "timestamp": "2026-06-20T10:00:00Z",
168
+ }
169
+ body = json.dumps(payload)
170
+ secret = "secret"
171
+ sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
172
+
173
+ event = parse_verified_webhook(body, sig, secret)
174
+ assert event is not None
175
+ assert event.request_id == "r1"
176
+ assert event.phone_number == "6281234567890"
177
+ assert event.timestamp == "2026-06-20T10:00:00Z"
178
+
179
+
180
+ def test_parse_verified_webhook_rejects_wrong_event() -> None:
181
+ payload = {"event": "verification.failed", "request_id": "r1", "phone_number": "628"}
182
+ body = json.dumps(payload)
183
+ secret = "secret"
184
+ sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
185
+
186
+ with pytest.raises(LessOTPError, match="unexpected webhook event"):
187
+ parse_verified_webhook(body, sig, secret)