verixo 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,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .mypy_cache/
7
+ .pytest_cache/
verixo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: verixo
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Verixo OTP-as-a-Service API
5
+ Project-URL: Homepage, https://verifiedcore.com
6
+ Project-URL: Repository, https://github.com/titicodes/verixo
7
+ License-Expression: MIT
8
+ Keywords: otp,phone-verification,sms-verification,verixo
9
+ Requires-Python: >=3.8
10
+ Requires-Dist: requests>=2.27
11
+ Requires-Dist: websocket-client>=1.6
12
+ Description-Content-Type: text/markdown
13
+
14
+ # verixo
15
+
16
+ Official Python SDK for the [Verixo](https://verifiedcore.com) OTP-as-a-Service API.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install verixo
22
+ ```
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ import os
28
+ import verixo
29
+
30
+ client = verixo.Client(os.environ["VC_API_KEY"])
31
+
32
+ result = client.numbers.search(
33
+ service_slug="whatsapp",
34
+ country_code="NG",
35
+ min_score=80,
36
+ limit=5,
37
+ )
38
+
39
+ session = client.numbers.purchase(
40
+ service_slug="whatsapp",
41
+ country_code="NG",
42
+ )
43
+
44
+ def handle(push):
45
+ print(f"OTP: {push.otp} in {push.latency_ms}ms")
46
+
47
+ client.subscribe(session.session_token, handle)
48
+ ```
49
+
50
+ Note: `purchase()` takes `service_slug` + `country_code`, not a specific
51
+ `number_id` -- the server picks the best available number by Health Score at
52
+ purchase time, since a candidate returned by `search()` may already be gone
53
+ by the time you'd reference it back.
54
+
55
+ ## Polling instead of real-time push
56
+
57
+ `subscribe()` runs a background thread holding a persistent WebSocket
58
+ connection, which isn't always practical (e.g. one-shot scripts, serverless
59
+ functions). Use `sessions.wait_for_otp()` instead:
60
+
61
+ ```python
62
+ result = client.sessions.wait_for_otp(session.session_token)
63
+ if result.status == "DELIVERED":
64
+ print(result.otp_code)
65
+ ```
66
+
67
+ ## Errors
68
+
69
+ All non-2xx responses raise `verixo.VerixoError` with `.status`, and usually
70
+ `.code`/`.detail` from the API's error body:
71
+
72
+ ```python
73
+ from verixo import VerixoError
74
+
75
+ try:
76
+ client.numbers.purchase(service_slug="whatsapp", country_code="NG")
77
+ except VerixoError as err:
78
+ if err.status == 402:
79
+ print("Insufficient wallet balance")
80
+ ```
81
+
82
+ ## Requirements
83
+
84
+ Python 3.8+.
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ python -m venv .venv
90
+ .venv/Scripts/activate # or `source .venv/bin/activate` on macOS/Linux
91
+ pip install -e .
92
+ python examples/quickstart.py # requires VC_API_KEY env var, see file header
93
+ ```
verixo-0.1.0/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # verixo
2
+
3
+ Official Python SDK for the [Verixo](https://verifiedcore.com) OTP-as-a-Service API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install verixo
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ import os
15
+ import verixo
16
+
17
+ client = verixo.Client(os.environ["VC_API_KEY"])
18
+
19
+ result = client.numbers.search(
20
+ service_slug="whatsapp",
21
+ country_code="NG",
22
+ min_score=80,
23
+ limit=5,
24
+ )
25
+
26
+ session = client.numbers.purchase(
27
+ service_slug="whatsapp",
28
+ country_code="NG",
29
+ )
30
+
31
+ def handle(push):
32
+ print(f"OTP: {push.otp} in {push.latency_ms}ms")
33
+
34
+ client.subscribe(session.session_token, handle)
35
+ ```
36
+
37
+ Note: `purchase()` takes `service_slug` + `country_code`, not a specific
38
+ `number_id` -- the server picks the best available number by Health Score at
39
+ purchase time, since a candidate returned by `search()` may already be gone
40
+ by the time you'd reference it back.
41
+
42
+ ## Polling instead of real-time push
43
+
44
+ `subscribe()` runs a background thread holding a persistent WebSocket
45
+ connection, which isn't always practical (e.g. one-shot scripts, serverless
46
+ functions). Use `sessions.wait_for_otp()` instead:
47
+
48
+ ```python
49
+ result = client.sessions.wait_for_otp(session.session_token)
50
+ if result.status == "DELIVERED":
51
+ print(result.otp_code)
52
+ ```
53
+
54
+ ## Errors
55
+
56
+ All non-2xx responses raise `verixo.VerixoError` with `.status`, and usually
57
+ `.code`/`.detail` from the API's error body:
58
+
59
+ ```python
60
+ from verixo import VerixoError
61
+
62
+ try:
63
+ client.numbers.purchase(service_slug="whatsapp", country_code="NG")
64
+ except VerixoError as err:
65
+ if err.status == 402:
66
+ print("Insufficient wallet balance")
67
+ ```
68
+
69
+ ## Requirements
70
+
71
+ Python 3.8+.
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ python -m venv .venv
77
+ .venv/Scripts/activate # or `source .venv/bin/activate` on macOS/Linux
78
+ pip install -e .
79
+ python examples/quickstart.py # requires VC_API_KEY env var, see file header
80
+ ```
@@ -0,0 +1,36 @@
1
+ """
2
+ Run against a local dev gateway:
3
+ VC_API_KEY=<your JWT or vc_test_/vc_live_ key> python examples/quickstart.py
4
+ """
5
+
6
+ import os
7
+ import time
8
+
9
+ import verixo
10
+
11
+ client = verixo.Client(
12
+ os.environ["VC_API_KEY"],
13
+ base_url=os.environ.get("VC_BASE_URL", "http://localhost:8080"),
14
+ )
15
+
16
+ result = client.numbers.search(service_slug="whatsapp", country_code="NG", min_score=0, limit=5)
17
+ top_score = result.numbers[0].health_score if result.numbers else None
18
+ print(f"Found {len(result.numbers)} numbers, top score: {top_score}")
19
+
20
+ session = client.numbers.purchase(service_slug="whatsapp", country_code="NG", test_mode=True)
21
+ print(f"Purchased {session.e164_number}, session {session.session_token}")
22
+
23
+ received = {"done": False}
24
+
25
+
26
+ def handle(push: verixo.OtpPush) -> None:
27
+ print(f"OTP: {push.otp} in {push.latency_ms}ms")
28
+ received["done"] = True
29
+
30
+
31
+ unsubscribe = client.subscribe(session.session_token, handle)
32
+ for _ in range(50):
33
+ if received["done"]:
34
+ break
35
+ time.sleep(0.2)
36
+ unsubscribe()
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "verixo"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Verixo OTP-as-a-Service API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.8"
12
+ keywords = ["otp", "sms-verification", "verixo", "phone-verification"]
13
+ dependencies = [
14
+ "requests>=2.27",
15
+ "websocket-client>=1.6",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://verifiedcore.com"
20
+ Repository = "https://github.com/titicodes/verixo"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/verixo"]
24
+
25
+ [tool.mypy]
26
+ strict = true
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable, Optional
4
+
5
+ from .client import HttpClient
6
+ from .errors import VerixoError
7
+ from .resources.analytics import Analytics
8
+ from .resources.numbers import Numbers
9
+ from .resources.sessions import Sessions
10
+ from .subscribe import subscribe as _subscribe
11
+ from .types import (
12
+ DeliveryRate,
13
+ DeliveryRates,
14
+ OtpPush,
15
+ PurchaseResult,
16
+ SearchNumbersResult,
17
+ SessionStatus,
18
+ VirtualNumber,
19
+ )
20
+
21
+ __all__ = [
22
+ "Client",
23
+ "VerixoError",
24
+ "VirtualNumber",
25
+ "SearchNumbersResult",
26
+ "PurchaseResult",
27
+ "SessionStatus",
28
+ "DeliveryRate",
29
+ "DeliveryRates",
30
+ "OtpPush",
31
+ ]
32
+
33
+
34
+ class Client:
35
+ def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: float = 30) -> None:
36
+ self._http = HttpClient(api_key, base_url, timeout=timeout)
37
+ self.numbers = Numbers(self._http)
38
+ self.sessions = Sessions(self._http)
39
+ self.analytics = Analytics(self._http)
40
+
41
+ def subscribe(self, session_token: str, on_otp: Callable[[OtpPush], None]) -> Callable[[], None]:
42
+ """
43
+ Get pushed the OTP for a session in real time, instead of polling
44
+ ``sessions.wait_for_otp()``. Returns an unsubscribe function.
45
+
46
+ Example::
47
+
48
+ session = client.numbers.purchase(service_slug="whatsapp", country_code="NG")
49
+
50
+ def handle(push):
51
+ print(f"OTP: {push.otp} in {push.latency_ms}ms")
52
+
53
+ client.subscribe(session.session_token, handle)
54
+ """
55
+ return _subscribe(self._http, session_token, on_otp)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ import requests
6
+
7
+ from .errors import VerixoError
8
+
9
+ _DEFAULT_BASE_URL = "https://api.verixo.com"
10
+
11
+
12
+ class HttpClient:
13
+ def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: float = 30) -> None:
14
+ if not api_key:
15
+ raise ValueError("Verixo: an API key (or JWT) is required, e.g. Client('vc_test_...')")
16
+ self.api_key = api_key
17
+ self.base_url = (base_url or _DEFAULT_BASE_URL).rstrip("/")
18
+ self.timeout = timeout
19
+ self._session = requests.Session()
20
+
21
+ @property
22
+ def _authorization_header(self) -> str:
23
+ # The gateway classifies the credential by sniffing the raw Authorization header for a
24
+ # vc_test_/vc_live_ prefix -- a "Bearer " prefix on an API key would make it misclassify
25
+ # the request as a (malformed) JWT and reject it, so API keys go out unprefixed.
26
+ is_api_key = self.api_key.startswith("vc_test_") or self.api_key.startswith("vc_live_")
27
+ return self.api_key if is_api_key else f"Bearer {self.api_key}"
28
+
29
+ def request(self, method: str, path: str, *, json: Optional[Dict[str, Any]] = None) -> Any:
30
+ response = self._session.request(
31
+ method,
32
+ f"{self.base_url}{path}",
33
+ json=json,
34
+ headers={"Authorization": self._authorization_header},
35
+ timeout=self.timeout,
36
+ )
37
+ if not response.ok:
38
+ raise VerixoError.from_response(response)
39
+ if response.status_code == 204 or not response.content:
40
+ return None
41
+ return response.json()
42
+
43
+ def get(self, path: str) -> Any:
44
+ return self.request("GET", path)
45
+
46
+ def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any:
47
+ return self.request("POST", path, json=json)
48
+
49
+ @property
50
+ def ws_origin(self) -> str:
51
+ """ws:// or wss:// origin derived from base_url, for the OTP push subscription."""
52
+ if self.base_url.startswith("https"):
53
+ return "wss" + self.base_url[len("https"):]
54
+ return "ws" + self.base_url[len("http"):]
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ _STATUS_FALLBACKS = {
6
+ 401: "Missing or invalid API key.",
7
+ 402: "Insufficient wallet balance.",
8
+ 403: "You don't have access to this resource.",
9
+ 404: "Not found.",
10
+ 408: "Request timed out.",
11
+ 422: "The request did not meet a required condition (e.g. min_score).",
12
+ 429: "Rate limit exceeded.",
13
+ }
14
+
15
+
16
+ class VerixoError(Exception):
17
+ """Raised for any non-2xx response from the Verixo API.
18
+
19
+ ``status`` is always present; ``code``/``detail`` are populated when the
20
+ gateway returns a JSON error body (most do) -- a small number of paths
21
+ (e.g. an invalid API key rejected before reaching a controller) return an
22
+ empty body, in which case only ``status`` and a generic message are
23
+ available.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ status: int,
31
+ code: Optional[str] = None,
32
+ detail: Optional[str] = None,
33
+ raw: Any = None,
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.status = status
37
+ self.code = code
38
+ self.detail = detail
39
+ self.raw = raw
40
+
41
+ @classmethod
42
+ def from_response(cls, response: Any) -> "VerixoError":
43
+ try:
44
+ body = response.json()
45
+ except ValueError:
46
+ body = None
47
+
48
+ obj = body if isinstance(body, dict) else {}
49
+ detail = obj.get("detail") or obj.get("error") or obj.get("message")
50
+ code = obj.get("grpcCode") or obj.get("code")
51
+ message = detail or _STATUS_FALLBACKS.get(
52
+ response.status_code, f"Request failed with status {response.status_code}"
53
+ )
54
+
55
+ return cls(message, status=response.status_code, code=code, detail=detail, raw=body)
File without changes
File without changes
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from ..client import HttpClient
4
+ from ..types import DeliveryRates
5
+
6
+
7
+ class Analytics:
8
+ def __init__(self, http: HttpClient) -> None:
9
+ self._http = http
10
+
11
+ def rates(self, service_slug: str = "whatsapp") -> DeliveryRates:
12
+ """Public delivery-rate stats by country for a service. No auth required by the API itself."""
13
+ data = self._http.get(f"/api/v1/analytics/rates?serviceSlug={service_slug}")
14
+ return DeliveryRates._from_json(data)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+ from urllib.parse import urlencode
5
+
6
+ from ..client import HttpClient
7
+ from ..types import PurchaseResult, SearchNumbersResult, _from_json
8
+
9
+
10
+ class Numbers:
11
+ def __init__(self, http: HttpClient) -> None:
12
+ self._http = http
13
+
14
+ def search(
15
+ self,
16
+ service_slug: str,
17
+ country_code: Optional[str] = None,
18
+ min_score: Optional[int] = None,
19
+ limit: Optional[int] = None,
20
+ ) -> SearchNumbersResult:
21
+ """Browse available numbers ranked by Health Score (TM). No purchase is made."""
22
+ params = {"serviceSlug": service_slug}
23
+ if country_code:
24
+ params["countryCode"] = country_code
25
+ if min_score is not None:
26
+ params["minScore"] = str(min_score)
27
+ if limit is not None:
28
+ params["limit"] = str(limit)
29
+ data = self._http.get(f"/api/v1/numbers/search?{urlencode(params)}")
30
+ return SearchNumbersResult._from_json(data)
31
+
32
+ def purchase(
33
+ self,
34
+ service_slug: str,
35
+ country_code: str,
36
+ *,
37
+ privacy_mode: bool = False,
38
+ test_mode: bool = False,
39
+ channel: str = "SMS",
40
+ ) -> PurchaseResult:
41
+ """
42
+ Buy a number for a service + country. The server picks the best available
43
+ candidate by Health Score automatically -- there's no number_id parameter,
44
+ since by the time you'd pass one back the number may already be gone.
45
+ """
46
+ body = {
47
+ "serviceSlug": service_slug,
48
+ "countryCode": country_code,
49
+ "privacyMode": privacy_mode,
50
+ "testMode": test_mode,
51
+ "channel": channel,
52
+ }
53
+ data = self._http.post("/api/v1/numbers/purchase", json=body)
54
+ return _from_json(PurchaseResult, data)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from urllib.parse import quote
5
+
6
+ from ..client import HttpClient
7
+ from ..types import SessionStatus, _from_json
8
+
9
+
10
+ class Sessions:
11
+ def __init__(self, http: HttpClient) -> None:
12
+ self._http = http
13
+
14
+ def get(self, session_token: str) -> SessionStatus:
15
+ """Poll a session's current delivery status."""
16
+ data = self._http.get(f"/api/v1/numbers/session/{quote(session_token)}")
17
+ return _from_json(SessionStatus, data)
18
+
19
+ def wait_for_otp(
20
+ self,
21
+ session_token: str,
22
+ timeout: float = 90,
23
+ interval: float = 2,
24
+ ) -> SessionStatus:
25
+ """
26
+ Poll until the session reaches a terminal state (DELIVERED, REFUNDED, EXPIRED,
27
+ FAILED) or the timeout elapses. Prefer ``Verixo.subscribe()`` for real-time
28
+ push -- this is a fallback for environments where a persistent WebSocket isn't
29
+ practical (e.g. serverless functions, simple scripts).
30
+ """
31
+ deadline = time.monotonic() + timeout
32
+ while True:
33
+ session = self.get(session_token)
34
+ if session.status != "PENDING":
35
+ return session
36
+ if time.monotonic() >= deadline:
37
+ return session
38
+ time.sleep(interval)
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Callable, Dict, List
8
+
9
+ import websocket
10
+
11
+ from .client import HttpClient
12
+ from .types import OtpPush, _from_json
13
+
14
+ _NULL = "\x00"
15
+
16
+
17
+ @dataclass
18
+ class _StompFrame:
19
+ command: str
20
+ headers: Dict[str, str]
21
+ body: str
22
+
23
+
24
+ def _build_frame(command: str, headers: Dict[str, str], body: str = "") -> str:
25
+ header_lines = "\n".join(f"{k}:{v}" for k, v in headers.items())
26
+ return f"{command}\n{header_lines}\n\n{body}{_NULL}"
27
+
28
+
29
+ def _parse_frame(raw: str) -> _StompFrame:
30
+ head, _, body = raw.partition("\n\n")
31
+ lines = head.split("\n")
32
+ command = lines[0]
33
+ headers: Dict[str, str] = {}
34
+ for line in lines[1:]:
35
+ if ":" in line:
36
+ key, _, value = line.partition(":")
37
+ headers[key] = value
38
+ return _StompFrame(command=command, headers=headers, body=body.rstrip(_NULL))
39
+
40
+
41
+ def subscribe(http: HttpClient, session_token: str, on_otp: Callable[[OtpPush], None]) -> Callable[[], None]:
42
+ """
43
+ Get pushed the OTP for a session in real time over the gateway's WebSocket
44
+ proxy (wss://.../ws -> delivery-service's STOMP broker, topic
45
+ /topic/otp/{session_token}), instead of polling ``sessions.wait_for_otp()``.
46
+
47
+ Runs the connection on a background thread and returns immediately with an
48
+ unsubscribe function. ``on_otp`` fires at most once per session (a session
49
+ only ever delivers one OTP).
50
+ """
51
+ subscribed_at = time.monotonic()
52
+ destination = f"/topic/otp/{session_token}"
53
+ buffer: List[str] = []
54
+
55
+ def handle_frame(raw: str) -> None:
56
+ frame = _parse_frame(raw)
57
+ if frame.command == "MESSAGE" and frame.headers.get("destination") == destination:
58
+ try:
59
+ payload = json.loads(frame.body)
60
+ except (ValueError, TypeError):
61
+ return
62
+ payload["latencyMs"] = int((time.monotonic() - subscribed_at) * 1000)
63
+ on_otp(_from_json(OtpPush, payload))
64
+
65
+ def on_open(ws: websocket.WebSocketApp) -> None:
66
+ ws.send(_build_frame("CONNECT", {"accept-version": "1.2", "heart-beat": "0,0"}))
67
+
68
+ def on_message(ws: websocket.WebSocketApp, message: str) -> None:
69
+ buffer.append(message)
70
+ joined = "".join(buffer)
71
+ if _NULL not in joined:
72
+ return
73
+ frames, _, remainder = joined.rpartition(_NULL)
74
+ buffer.clear()
75
+ if remainder:
76
+ buffer.append(remainder)
77
+ for raw in filter(None, frames.split(_NULL)):
78
+ if raw.strip().startswith("CONNECTED"):
79
+ ws.send(_build_frame("SUBSCRIBE", {"id": "sub-0", "destination": destination}))
80
+ else:
81
+ handle_frame(raw)
82
+
83
+ app = websocket.WebSocketApp(
84
+ f"{http.ws_origin}/ws",
85
+ on_open=on_open,
86
+ on_message=on_message,
87
+ )
88
+ thread = threading.Thread(target=app.run_forever, kwargs={"reconnect": 5}, daemon=True)
89
+ thread.start()
90
+
91
+ def unsubscribe() -> None:
92
+ app.close()
93
+
94
+ return unsubscribe
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, fields
5
+ from typing import Any, Dict, List, Optional, Type, TypeVar
6
+
7
+ T = TypeVar("T")
8
+
9
+ _CAMEL_RE = re.compile(r"(?<!^)(?=[A-Z])")
10
+
11
+
12
+ def _to_snake(name: str) -> str:
13
+ return _CAMEL_RE.sub("_", name).lower()
14
+
15
+
16
+ def _from_json(cls: Type[T], data: Dict[str, Any]) -> T:
17
+ """Builds a dataclass from a camelCase JSON dict, mapping keys to snake_case fields."""
18
+ snake = {_to_snake(k): v for k, v in data.items()}
19
+ known = {f.name for f in fields(cls)} # type: ignore[arg-type]
20
+ return cls(**{k: v for k, v in snake.items() if k in known})
21
+
22
+
23
+ @dataclass
24
+ class VirtualNumber:
25
+ number_id: str
26
+ number: str
27
+ e164_number: str
28
+ health_score: int
29
+ number_type: str
30
+ country_code: str
31
+ country_name: str
32
+ country_flag: str
33
+ price_usd: float
34
+ delivery_rate: float
35
+ avg_latency_ms: Optional[int]
36
+ available: bool
37
+ score_color: str
38
+ carrier_name: str
39
+
40
+
41
+ @dataclass
42
+ class SearchNumbersResult:
43
+ numbers: List[VirtualNumber]
44
+ total_available: int
45
+ country_code: str
46
+ service_slug: str
47
+
48
+ @classmethod
49
+ def _from_json(cls, data: Dict[str, Any]) -> "SearchNumbersResult":
50
+ result = _from_json(cls, data)
51
+ result.numbers = [_from_json(VirtualNumber, n) for n in data.get("numbers", [])]
52
+ return result
53
+
54
+
55
+ @dataclass
56
+ class PurchaseResult:
57
+ session_token: str
58
+ purchase_id: str
59
+ web_socket_topic: str
60
+ e164_number: str
61
+ health_score_at_purchase: int
62
+ score_color: str
63
+ carrier_type: str
64
+ aggregator_type: str
65
+ price_usd: float
66
+ sla_remaining_seconds: int
67
+ sla_expires_at: str
68
+ privacy_mode: bool
69
+ confidence_label: str
70
+ channel: str
71
+
72
+
73
+ @dataclass
74
+ class SessionStatus:
75
+ session_token: str
76
+ status: str # "PENDING" | "DELIVERED" | "REFUNDED" | "EXPIRED" | "FAILED"
77
+ otp_code: str
78
+ sla_remaining_seconds: int
79
+ failure_reason: str
80
+ aggregator_type: str
81
+ health_score: int
82
+ privacy_mode: bool
83
+ channel: str
84
+
85
+
86
+ @dataclass
87
+ class DeliveryRate:
88
+ country_code: str
89
+ country_name: str
90
+ country_flag: str
91
+ rate: float
92
+ latency: str
93
+ total_deliveries: int
94
+ updated_at: str
95
+
96
+
97
+ @dataclass
98
+ class DeliveryRates:
99
+ rates: List[DeliveryRate]
100
+ updated_at: str
101
+
102
+ @classmethod
103
+ def _from_json(cls, data: Dict[str, Any]) -> "DeliveryRates":
104
+ return cls(
105
+ rates=[_from_json(DeliveryRate, r) for r in data.get("rates", [])],
106
+ updated_at=data.get("updatedAt", ""),
107
+ )
108
+
109
+
110
+ @dataclass
111
+ class OtpPush:
112
+ session_token: str
113
+ otp: str
114
+ e164_number: str
115
+ service_slug: str
116
+ privacy_mode: bool
117
+ delivered_at: str
118
+ latency_ms: int
119
+ """Milliseconds between calling subscribe() and this push arriving -- not from the server."""