eveses 0.1.0__py3-none-any.whl

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.
eveses/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ eveses — Official Python SDK.
3
+
4
+ Quickstart:
5
+
6
+ from eveses import Eveses
7
+ client = Eveses(api_key="sk_…")
8
+ order = client.activations.create(country="ua", service="telegram")
9
+ wallet = client.wallet.balance()
10
+ services = client.catalog.services(mode="activation", country="ua")
11
+
12
+ Webhook verification:
13
+
14
+ from eveses import Webhooks
15
+ ok = Webhooks.verify(raw_body, signature_header, secret, timestamp=ts_header)
16
+ """
17
+
18
+ from .activations import Activations, Order, OrderSms, OrderSmsBundle
19
+ from .catalog import (
20
+ Catalog,
21
+ CatalogCountriesResponse,
22
+ CatalogPricingDuration,
23
+ CatalogPricingResponse,
24
+ CatalogServiceWithDurations,
25
+ CatalogServicesResponse,
26
+ )
27
+ from .client import Eveses
28
+ from .exceptions import (
29
+ EvesesAuthError,
30
+ EvesesError,
31
+ EvesesForbiddenError,
32
+ EvesesNotFoundError,
33
+ EvesesRateLimitError,
34
+ EvesesServerError,
35
+ EvesesValidationError,
36
+ )
37
+ from .wallet import Wallet, WalletBalance
38
+ from .webhooks import Webhooks, verify_webhook
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ __all__ = [
43
+ "Eveses",
44
+ "Activations",
45
+ "Catalog",
46
+ "Wallet",
47
+ "Webhooks",
48
+ "verify_webhook",
49
+ "Order",
50
+ "OrderSms",
51
+ "OrderSmsBundle",
52
+ "WalletBalance",
53
+ "CatalogCountriesResponse",
54
+ "CatalogServicesResponse",
55
+ "CatalogPricingResponse",
56
+ "CatalogServiceWithDurations",
57
+ "CatalogPricingDuration",
58
+ "EvesesError",
59
+ "EvesesAuthError",
60
+ "EvesesForbiddenError",
61
+ "EvesesNotFoundError",
62
+ "EvesesValidationError",
63
+ "EvesesRateLimitError",
64
+ "EvesesServerError",
65
+ "__version__",
66
+ ]
eveses/activations.py ADDED
@@ -0,0 +1,157 @@
1
+ """
2
+ Activations / orders namespace.
3
+
4
+ Note: the public OpenAPI spec exposes orders under `/api/account/orders/*`.
5
+ There is no dedicated `/api/v1/activations` route today; for API-key
6
+ consumers (kind=api_key Sanctum tokens), v1 is a thin wrapper around the
7
+ account-scoped controllers. This module hits the account-scoped paths.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
14
+
15
+ if TYPE_CHECKING: # pragma: no cover
16
+ from .client import Eveses
17
+
18
+
19
+ @dataclass
20
+ class Order:
21
+ order_id: str
22
+ status: str
23
+ phone: Optional[str] = None
24
+ country: Optional[str] = None
25
+ service: Optional[str] = None
26
+ mode: Optional[str] = None
27
+ price_cents: Optional[int] = None
28
+ expires_at: Optional[str] = None
29
+ created_at: Optional[str] = None
30
+ raw: Dict[str, Any] = field(default_factory=dict)
31
+
32
+
33
+ @dataclass
34
+ class OrderSms:
35
+ id: int
36
+ text: str
37
+ sender: Optional[str] = None
38
+ received_at: Optional[str] = None
39
+
40
+
41
+ @dataclass
42
+ class OrderSmsBundle:
43
+ order_id: str
44
+ stored: List[OrderSms]
45
+ fresh: List[OrderSms]
46
+
47
+
48
+ class Activations:
49
+ """Wrapper around `/api/account/orders/*`."""
50
+
51
+ def __init__(self, client: "Eveses") -> None:
52
+ self._client = client
53
+
54
+ def create(
55
+ self,
56
+ *,
57
+ country: str,
58
+ service: str,
59
+ mode: str = "activation",
60
+ duration_minutes: Optional[int] = None,
61
+ idempotency_key: Optional[str] = None,
62
+ max_price_cents: Optional[int] = None,
63
+ ) -> Order:
64
+ """Provision a number for a country/service. Returns the created order."""
65
+ body: Dict[str, Any] = {"mode": mode, "country": country, "service": service}
66
+ if duration_minutes is not None:
67
+ body["duration_minutes"] = duration_minutes
68
+ if idempotency_key is not None:
69
+ body["idempotency_key"] = idempotency_key
70
+ if max_price_cents is not None:
71
+ body["max_price_cents"] = max_price_cents
72
+
73
+ headers: Dict[str, str] = {}
74
+ if idempotency_key:
75
+ headers["Idempotency-Key"] = idempotency_key
76
+
77
+ res = self._client.request(
78
+ "POST",
79
+ "/api/account/orders",
80
+ json_body=body,
81
+ headers=headers,
82
+ )
83
+ return _map_order(_unwrap(res))
84
+
85
+ def get(self, order_id: str) -> Order:
86
+ res = self._client.request("GET", f"/api/account/orders/{_quote(order_id)}")
87
+ return _map_order(_unwrap(res))
88
+
89
+ def cancel(self, order_id: str) -> Order:
90
+ """Release the number and refund the user (where supported)."""
91
+ res = self._client.request("POST", f"/api/account/orders/{_quote(order_id)}/cancel")
92
+ return _map_order(_unwrap(res))
93
+
94
+ def finish(self, order_id: str) -> Order:
95
+ """Mark the order completed once the SMS has been consumed."""
96
+ res = self._client.request("POST", f"/api/account/orders/{_quote(order_id)}/finish")
97
+ return _map_order(_unwrap(res))
98
+
99
+ def sms(self, order_id: str) -> OrderSmsBundle:
100
+ """
101
+ Get all SMS messages for an order. Combines `stored` (delivered to us
102
+ via webhook) with `fresh` (pulled from the upstream provider on demand).
103
+ """
104
+ res = self._client.request("GET", f"/api/account/orders/{_quote(order_id)}/sms")
105
+ data = _unwrap(res)
106
+ return OrderSmsBundle(
107
+ order_id=str(data.get("order_id") or order_id),
108
+ stored=[_map_sms(m) for m in (data.get("stored") or [])],
109
+ fresh=[_map_sms(m) for m in (data.get("fresh") or [])],
110
+ )
111
+
112
+
113
+ # --------------------------------------------------------------- internals --
114
+ def _unwrap(payload: Any) -> Dict[str, Any]:
115
+ if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
116
+ return payload["data"]
117
+ if isinstance(payload, dict):
118
+ return payload
119
+ return {}
120
+
121
+
122
+ def _map_order(d: Dict[str, Any]) -> Order:
123
+ return Order(
124
+ order_id=str(d.get("order_id") or ""),
125
+ status=str(d.get("status") or "pending"),
126
+ phone=_str_or_none(d.get("phone")),
127
+ country=_str_or_none(d.get("country")),
128
+ service=_str_or_none(d.get("service")),
129
+ mode=_str_or_none(d.get("mode")),
130
+ price_cents=_int_or_none(d.get("price_cents")),
131
+ expires_at=_str_or_none(d.get("expires_at")),
132
+ created_at=_str_or_none(d.get("created_at")),
133
+ raw=dict(d),
134
+ )
135
+
136
+
137
+ def _map_sms(m: Dict[str, Any]) -> OrderSms:
138
+ return OrderSms(
139
+ id=int(m.get("id") or 0),
140
+ text=str(m.get("text") or ""),
141
+ sender=_str_or_none(m.get("sender")),
142
+ received_at=_str_or_none(m.get("received_at")),
143
+ )
144
+
145
+
146
+ def _str_or_none(v: Any) -> Optional[str]:
147
+ return v if isinstance(v, str) else None
148
+
149
+
150
+ def _int_or_none(v: Any) -> Optional[int]:
151
+ return v if isinstance(v, int) else None
152
+
153
+
154
+ def _quote(value: str) -> str:
155
+ from urllib.parse import quote
156
+
157
+ return quote(value, safe="")
eveses/catalog.py ADDED
@@ -0,0 +1,216 @@
1
+ """
2
+ Catalog namespace — read-only metadata used to drive the UX before
3
+ creating an order: which countries / services are available, and how
4
+ much each combination costs.
5
+
6
+ Targets the API-key-authenticated v1 routes:
7
+
8
+ GET /api/v1/numbers/countries?mode=
9
+ GET /api/v1/numbers/products?mode= (the "services" list)
10
+ GET /api/v1/numbers/pricing?mode=&country=&product=&duration=
11
+
12
+ Wire-shape note: the v1 list endpoint is named `products` for legacy
13
+ reasons — it returns the same flat string list the rest of the SDK
14
+ calls "services". The pricing endpoint takes `product=` on the wire,
15
+ which we accept here under the friendlier `service` name.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
22
+
23
+ if TYPE_CHECKING: # pragma: no cover
24
+ from .client import Eveses
25
+
26
+
27
+ @dataclass
28
+ class CatalogCountriesResponse:
29
+ mode: str
30
+ countries: List[str] = field(default_factory=list)
31
+
32
+
33
+ @dataclass
34
+ class CatalogServicesResponse:
35
+ mode: str
36
+ services: List[str] = field(default_factory=list)
37
+ country: Optional[str] = None
38
+ currency: Optional[str] = None
39
+
40
+
41
+ @dataclass
42
+ class CatalogPricingDuration:
43
+ duration_minutes: int
44
+ price_cents: Optional[int] = None
45
+ price: Optional[float] = None
46
+ currency: Optional[str] = None
47
+ available: Optional[bool] = None
48
+ raw: Dict[str, Any] = field(default_factory=dict)
49
+
50
+
51
+ @dataclass
52
+ class CatalogServiceWithDurations:
53
+ name: str
54
+ durations: List[CatalogPricingDuration] = field(default_factory=list)
55
+
56
+
57
+ @dataclass
58
+ class CatalogPricingResponse:
59
+ mode: str
60
+ country: str
61
+ services: List[CatalogServiceWithDurations] = field(default_factory=list)
62
+ currency: Optional[str] = None
63
+ service: Optional[str] = None
64
+
65
+
66
+ class Catalog:
67
+ """Wrapper around `/api/v1/numbers/{countries,products,pricing}`."""
68
+
69
+ def __init__(self, client: "Eveses") -> None:
70
+ self._client = client
71
+
72
+ def countries(self, *, mode: str = "activation") -> CatalogCountriesResponse:
73
+ """List ISO-3166-1 alpha-2 country codes that have stock for ``mode``."""
74
+ res = self._client.request(
75
+ "GET",
76
+ "/api/v1/numbers/countries",
77
+ params={"mode": mode},
78
+ )
79
+ d = _unwrap(res)
80
+ countries_raw = d.get("countries") or []
81
+ countries = [str(c) for c in countries_raw] if isinstance(countries_raw, list) else []
82
+ return CatalogCountriesResponse(
83
+ mode=str(d.get("mode") or mode),
84
+ countries=countries,
85
+ )
86
+
87
+ def services(
88
+ self,
89
+ *,
90
+ mode: str = "activation",
91
+ country: Optional[str] = None,
92
+ currency: Optional[str] = None,
93
+ ) -> CatalogServicesResponse:
94
+ """
95
+ List service / product codes available globally for ``mode``.
96
+
97
+ ``country`` and ``currency`` are accepted for symmetry with the
98
+ broader catalog API but are currently informational on the v1
99
+ endpoint, which returns the unified product list.
100
+ """
101
+ res = self._client.request(
102
+ "GET",
103
+ "/api/v1/numbers/products",
104
+ params={"mode": mode},
105
+ )
106
+ d = _unwrap(res)
107
+ products_raw = d.get("products") or []
108
+ services = [str(p) for p in products_raw] if isinstance(products_raw, list) else []
109
+ return CatalogServicesResponse(
110
+ mode=str(d.get("mode") or mode),
111
+ services=services,
112
+ country=country.lower() if isinstance(country, str) and country else None,
113
+ currency=currency.upper() if isinstance(currency, str) and currency else None,
114
+ )
115
+
116
+ def pricing(
117
+ self,
118
+ *,
119
+ country: str,
120
+ service: str,
121
+ mode: str = "activation",
122
+ currency: Optional[str] = None,
123
+ duration_minutes: Optional[int] = None,
124
+ ) -> CatalogPricingResponse:
125
+ """Fetch pricing for a country/service pair (optionally for a specific duration)."""
126
+ if not country:
127
+ raise ValueError("country is required")
128
+ if not service:
129
+ raise ValueError("service is required")
130
+
131
+ params: Dict[str, Any] = {
132
+ "mode": mode,
133
+ "country": country.lower(),
134
+ "product": service,
135
+ }
136
+ if currency:
137
+ params["currency"] = currency.upper()
138
+ if duration_minutes is not None:
139
+ params["duration"] = duration_minutes
140
+
141
+ res = self._client.request(
142
+ "GET",
143
+ "/api/v1/numbers/pricing",
144
+ params=params,
145
+ )
146
+ d = _unwrap(res)
147
+
148
+ services_raw = d.get("services") or []
149
+ services: List[CatalogServiceWithDurations] = []
150
+ if isinstance(services_raw, list):
151
+ for entry in services_raw:
152
+ services.append(_map_service_entry(entry))
153
+
154
+ return CatalogPricingResponse(
155
+ mode=str(d.get("mode") or mode),
156
+ country=str(d.get("country") or country.lower()),
157
+ currency=_str_or_none(d.get("currency")) or (currency.upper() if currency else None),
158
+ service=service,
159
+ services=services,
160
+ )
161
+
162
+
163
+ # --------------------------------------------------------------- internals --
164
+ def _unwrap(payload: Any) -> Dict[str, Any]:
165
+ if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
166
+ return payload["data"]
167
+ if isinstance(payload, dict):
168
+ return payload
169
+ return {}
170
+
171
+
172
+ def _map_service_entry(entry: Any) -> CatalogServiceWithDurations:
173
+ if not isinstance(entry, dict):
174
+ return CatalogServiceWithDurations(name="", durations=[])
175
+ durations_raw = entry.get("durations") or []
176
+ durations: List[CatalogPricingDuration] = []
177
+ if isinstance(durations_raw, list):
178
+ for d in durations_raw:
179
+ durations.append(_map_duration(d))
180
+ return CatalogServiceWithDurations(
181
+ name=str(entry.get("name") or ""),
182
+ durations=durations,
183
+ )
184
+
185
+
186
+ def _map_duration(d: Any) -> CatalogPricingDuration:
187
+ if not isinstance(d, dict):
188
+ return CatalogPricingDuration(duration_minutes=0)
189
+ available = d.get("available")
190
+ if not isinstance(available, bool):
191
+ in_stock = d.get("in_stock")
192
+ available = in_stock if isinstance(in_stock, bool) else None
193
+ return CatalogPricingDuration(
194
+ duration_minutes=int(d.get("duration_minutes") or 0),
195
+ price_cents=_int_or_none(d.get("price_cents")),
196
+ price=_float_or_none(d.get("price")),
197
+ currency=_str_or_none(d.get("currency")),
198
+ available=available,
199
+ raw=dict(d),
200
+ )
201
+
202
+
203
+ def _str_or_none(v: Any) -> Optional[str]:
204
+ return v if isinstance(v, str) else None
205
+
206
+
207
+ def _int_or_none(v: Any) -> Optional[int]:
208
+ return v if isinstance(v, int) and not isinstance(v, bool) else None
209
+
210
+
211
+ def _float_or_none(v: Any) -> Optional[float]:
212
+ if isinstance(v, bool):
213
+ return None
214
+ if isinstance(v, (int, float)):
215
+ return float(v)
216
+ return None
eveses/client.py ADDED
@@ -0,0 +1,198 @@
1
+ """
2
+ Eveses client. Hand-rolled wrapper around `requests` with:
3
+ - Bearer auth header
4
+ - JSON serialisation
5
+ - Idempotency-Key header passthrough
6
+ - One automatic retry on 429 (using Retry-After if present)
7
+ - Typed exceptions for non-2xx responses
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ from typing import Any, Dict, Mapping, Optional
15
+
16
+ import requests
17
+
18
+ from .exceptions import (
19
+ EvesesAuthError,
20
+ EvesesError,
21
+ EvesesForbiddenError,
22
+ EvesesNotFoundError,
23
+ EvesesRateLimitError,
24
+ EvesesServerError,
25
+ EvesesValidationError,
26
+ )
27
+
28
+ DEFAULT_BASE_URL = "https://api.eveses.io"
29
+ DEFAULT_TIMEOUT_S = 30.0
30
+ DEFAULT_USER_AGENT = "eveses-python/0.1.0"
31
+
32
+
33
+ class Eveses:
34
+ """
35
+ Top-level SDK client.
36
+
37
+ Example:
38
+ from eveses import Eveses
39
+ client = Eveses(api_key=os.environ["EVESES_API_KEY"])
40
+ order = client.activations.create(country="ua", service="telegram")
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ api_key: str,
46
+ *,
47
+ base_url: str = DEFAULT_BASE_URL,
48
+ timeout: float = DEFAULT_TIMEOUT_S,
49
+ session: Optional[requests.Session] = None,
50
+ default_headers: Optional[Mapping[str, str]] = None,
51
+ user_agent: str = DEFAULT_USER_AGENT,
52
+ ) -> None:
53
+ if not api_key:
54
+ raise EvesesError("api_key is required", 0)
55
+ self.api_key = api_key
56
+ self.base_url = base_url.rstrip("/")
57
+ self.timeout = timeout
58
+ self.user_agent = user_agent
59
+ self._session = session or requests.Session()
60
+ self._default_headers = dict(default_headers or {})
61
+
62
+ # Lazy import to avoid circular references at module-import time.
63
+ from .activations import Activations
64
+ from .catalog import Catalog
65
+ from .wallet import Wallet
66
+ from .webhooks import Webhooks
67
+
68
+ self.activations = Activations(self)
69
+ self.wallet = Wallet(self)
70
+ self.catalog = Catalog(self)
71
+ # Static-like helper. Also importable as `from eveses import Webhooks`.
72
+ self.webhooks = Webhooks
73
+
74
+ # -------------------------------------------------------------- request --
75
+ def request(
76
+ self,
77
+ method: str,
78
+ path: str,
79
+ *,
80
+ params: Optional[Mapping[str, Any]] = None,
81
+ json_body: Optional[Mapping[str, Any]] = None,
82
+ headers: Optional[Mapping[str, str]] = None,
83
+ ) -> Any:
84
+ """Send a single authenticated request and return parsed JSON."""
85
+ url = self._build_url(path)
86
+ merged_headers: Dict[str, str] = {
87
+ "Authorization": f"Bearer {self.api_key}",
88
+ "Accept": "application/json",
89
+ "User-Agent": self.user_agent,
90
+ }
91
+ merged_headers.update(self._default_headers)
92
+ if headers:
93
+ merged_headers.update(headers)
94
+ if json_body is not None:
95
+ merged_headers.setdefault("Content-Type", "application/json")
96
+
97
+ body_str: Optional[str] = None
98
+ if json_body is not None:
99
+ body_str = json.dumps(json_body, separators=(",", ":"))
100
+
101
+ return self._execute_with_retry(method, url, merged_headers, params, body_str)
102
+
103
+ def _execute_with_retry(
104
+ self,
105
+ method: str,
106
+ url: str,
107
+ headers: Dict[str, str],
108
+ params: Optional[Mapping[str, Any]],
109
+ body: Optional[str],
110
+ attempt: int = 0,
111
+ ) -> Any:
112
+ try:
113
+ response = self._session.request(
114
+ method=method,
115
+ url=url,
116
+ headers=headers,
117
+ params=_clean_params(params),
118
+ data=body,
119
+ timeout=self.timeout,
120
+ )
121
+ except requests.RequestException as exc:
122
+ raise EvesesError(f"Network error: {exc}", 0) from exc
123
+
124
+ if response.status_code == 429 and attempt == 0:
125
+ time.sleep(_parse_retry_after(response.headers.get("Retry-After")))
126
+ return self._execute_with_retry(method, url, headers, params, body, attempt + 1)
127
+
128
+ return self._parse_response(response)
129
+
130
+ def _parse_response(self, response: requests.Response) -> Any:
131
+ content_type = response.headers.get("Content-Type", "")
132
+ parsed: Any
133
+ if "application/json" in content_type:
134
+ try:
135
+ parsed = response.json()
136
+ except ValueError:
137
+ parsed = None
138
+ else:
139
+ parsed = response.text or None
140
+
141
+ if response.ok:
142
+ return parsed
143
+
144
+ message = _extract_message(parsed) or response.reason or f"HTTP {response.status_code}"
145
+ status = response.status_code
146
+
147
+ if status == 401:
148
+ raise EvesesAuthError(message, body=parsed)
149
+ if status == 403:
150
+ raise EvesesForbiddenError(message, body=parsed)
151
+ if status == 404:
152
+ raise EvesesNotFoundError(message, body=parsed)
153
+ if status in (400, 422):
154
+ raise EvesesValidationError(message, status, body=parsed)
155
+ if status == 429:
156
+ raise EvesesRateLimitError(
157
+ message,
158
+ retry_after=_parse_retry_after(response.headers.get("Retry-After")),
159
+ body=parsed,
160
+ )
161
+ if status >= 500:
162
+ raise EvesesServerError(message, status, body=parsed)
163
+ raise EvesesError(message, status, body=parsed)
164
+
165
+ # --------------------------------------------------------------- helpers --
166
+ def _build_url(self, path: str) -> str:
167
+ if not path.startswith("/"):
168
+ path = "/" + path
169
+ return f"{self.base_url}{path}"
170
+
171
+
172
+ def _clean_params(params: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
173
+ if not params:
174
+ return None
175
+ return {k: v for k, v in params.items() if v is not None}
176
+
177
+
178
+ def _parse_retry_after(value: Optional[str]) -> float:
179
+ if not value:
180
+ return 1.0
181
+ try:
182
+ seconds = int(value)
183
+ except (TypeError, ValueError):
184
+ return 1.0
185
+ if seconds < 0:
186
+ return 1.0
187
+ return float(min(seconds, 60))
188
+
189
+
190
+ def _extract_message(body: Any) -> Optional[str]:
191
+ if isinstance(body, dict):
192
+ msg = body.get("message")
193
+ if isinstance(msg, str):
194
+ return msg
195
+ err = body.get("error")
196
+ if isinstance(err, str):
197
+ return err
198
+ return None
eveses/exceptions.py ADDED
@@ -0,0 +1,76 @@
1
+ """
2
+ Exception hierarchy for the Eveses SDK.
3
+
4
+ Every non-2xx response is converted into an EvesesError subclass:
5
+ 400/422 -> EvesesValidationError
6
+ 401 -> EvesesAuthError
7
+ 403 -> EvesesForbiddenError
8
+ 404 -> EvesesNotFoundError
9
+ 429 -> EvesesRateLimitError (after the 1 auto-retry is exhausted)
10
+ 5xx -> EvesesServerError
11
+ other -> EvesesError
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, List, Optional
17
+
18
+
19
+ class EvesesError(Exception):
20
+ """Base class for all SDK errors."""
21
+
22
+ def __init__(
23
+ self,
24
+ message: str,
25
+ status: int = 0,
26
+ *,
27
+ code: Optional[str] = None,
28
+ body: Any = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ self.status = status
33
+ self.code = code
34
+ self.body = body
35
+
36
+ def __str__(self) -> str: # pragma: no cover - cosmetic
37
+ return f"{self.__class__.__name__}({self.status}): {self.message}"
38
+
39
+
40
+ class EvesesAuthError(EvesesError):
41
+ def __init__(self, message: str = "Unauthenticated", body: Any = None) -> None:
42
+ super().__init__(message, 401, code="unauthenticated", body=body)
43
+
44
+
45
+ class EvesesForbiddenError(EvesesError):
46
+ def __init__(self, message: str = "Forbidden", body: Any = None) -> None:
47
+ super().__init__(message, 403, code="forbidden", body=body)
48
+
49
+
50
+ class EvesesNotFoundError(EvesesError):
51
+ def __init__(self, message: str = "Not found", body: Any = None) -> None:
52
+ super().__init__(message, 404, code="not_found", body=body)
53
+
54
+
55
+ class EvesesValidationError(EvesesError):
56
+ def __init__(self, message: str, status: int, body: Any = None) -> None:
57
+ super().__init__(message or "Validation failed", status, code="validation_failed", body=body)
58
+ self.errors: Optional[Dict[str, List[str]]] = None
59
+ if isinstance(body, dict) and isinstance(body.get("errors"), dict):
60
+ self.errors = body["errors"]
61
+
62
+
63
+ class EvesesRateLimitError(EvesesError):
64
+ def __init__(
65
+ self,
66
+ message: str = "Rate limited",
67
+ retry_after: Optional[float] = None,
68
+ body: Any = None,
69
+ ) -> None:
70
+ super().__init__(message, 429, code="rate_limited", body=body)
71
+ self.retry_after = retry_after
72
+
73
+
74
+ class EvesesServerError(EvesesError):
75
+ def __init__(self, message: str, status: int, body: Any = None) -> None:
76
+ super().__init__(message or "Server error", status, code="server_error", body=body)
eveses/wallet.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ Wallet namespace. Hits `/api/account/wallet`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Any, Dict
9
+
10
+ if TYPE_CHECKING: # pragma: no cover
11
+ from .client import Eveses
12
+
13
+
14
+ @dataclass
15
+ class WalletBalance:
16
+ balance: int
17
+ held_balance: int
18
+ available_balance: int
19
+ currency: str
20
+
21
+
22
+ class Wallet:
23
+ def __init__(self, client: "Eveses") -> None:
24
+ self._client = client
25
+
26
+ def balance(self) -> WalletBalance:
27
+ """Snapshot of total / held / available balance."""
28
+ res = self._client.request("GET", "/api/account/wallet")
29
+ d = _unwrap(res)
30
+ return WalletBalance(
31
+ balance=_int(d.get("balance"), 0),
32
+ held_balance=_int(d.get("held_balance"), 0),
33
+ available_balance=_int(d.get("available_balance"), 0),
34
+ currency=str(d.get("currency") or "USD"),
35
+ )
36
+
37
+
38
+ def _unwrap(payload: Any) -> Dict[str, Any]:
39
+ if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
40
+ return payload["data"]
41
+ if isinstance(payload, dict):
42
+ return payload
43
+ return {}
44
+
45
+
46
+ def _int(value: Any, default: int) -> int:
47
+ return value if isinstance(value, int) else default
eveses/webhooks.py ADDED
@@ -0,0 +1,101 @@
1
+ """
2
+ Webhook signature verification.
3
+
4
+ Eveses signs every webhook delivery with HMAC-SHA256 over `f"{timestamp}.{body}"`,
5
+ using the endpoint's signing secret. Two headers carry the proof:
6
+
7
+ X-Eveses-Signature -> "sha256=<hex>"
8
+ X-Eveses-Timestamp -> unix seconds
9
+
10
+ Use `Webhooks.verify(...)`. Always pass the **raw** request body (bytes or str),
11
+ not the parsed JSON — round-tripping through json.loads/json.dumps reorders
12
+ keys and breaks the signature.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import hmac
19
+ import time
20
+ from typing import Optional, Union
21
+
22
+
23
+ class Webhooks:
24
+ """Static-like helpers for webhook verification."""
25
+
26
+ @staticmethod
27
+ def verify(
28
+ raw_body: Union[str, bytes],
29
+ signature_header: Optional[str],
30
+ secret: str,
31
+ *,
32
+ timestamp: Optional[Union[str, int, float]] = None,
33
+ tolerance_seconds: int = 300,
34
+ ) -> bool:
35
+ """
36
+ Verify an Eveses webhook signature.
37
+
38
+ :param raw_body: The raw request body (str or bytes).
39
+ :param signature_header: Value of `X-Eveses-Signature`, e.g. "sha256=abc123".
40
+ :param secret: The endpoint signing secret.
41
+ :param timestamp: Value of `X-Eveses-Timestamp` (unix seconds).
42
+ :param tolerance_seconds: Reject timestamps drifting more than this from now.
43
+ Pass 0 to disable the staleness check.
44
+ :returns: True iff the signature is valid and within tolerance.
45
+ """
46
+ if not signature_header or not secret:
47
+ return False
48
+
49
+ expected_hex = _strip_prefix(signature_header)
50
+ if not expected_hex or not all(c in "0123456789abcdefABCDEF" for c in expected_hex):
51
+ return False
52
+
53
+ if timestamp is None or timestamp == "":
54
+ return False
55
+ try:
56
+ ts = int(timestamp)
57
+ except (TypeError, ValueError):
58
+ try:
59
+ ts = int(float(timestamp))
60
+ except (TypeError, ValueError):
61
+ return False
62
+ if ts <= 0:
63
+ return False
64
+
65
+ if tolerance_seconds > 0:
66
+ now = int(time.time())
67
+ if abs(now - ts) > tolerance_seconds:
68
+ return False
69
+
70
+ body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
71
+ signing_input = f"{ts}.".encode("utf-8") + body_bytes
72
+ computed = hmac.new(
73
+ secret.encode("utf-8"),
74
+ signing_input,
75
+ hashlib.sha256,
76
+ ).hexdigest()
77
+ return hmac.compare_digest(computed, expected_hex.lower())
78
+
79
+
80
+ def _strip_prefix(value: str) -> str:
81
+ trimmed = value.strip()
82
+ return trimmed[len("sha256=") :] if trimmed.startswith("sha256=") else trimmed
83
+
84
+
85
+ # Module-level convenience alias matching the spec's `verify_webhook(...)`.
86
+ def verify_webhook(
87
+ raw_body: Union[str, bytes],
88
+ signature_header: Optional[str],
89
+ secret: str,
90
+ *,
91
+ timestamp: Optional[Union[str, int, float]] = None,
92
+ tolerance_seconds: int = 300,
93
+ ) -> bool:
94
+ """Functional alias for :py:meth:`Webhooks.verify`."""
95
+ return Webhooks.verify(
96
+ raw_body,
97
+ signature_header,
98
+ secret,
99
+ timestamp=timestamp,
100
+ tolerance_seconds=tolerance_seconds,
101
+ )
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: eveses
3
+ Version: 0.1.0
4
+ Summary: Official Eveses SDK — activations, wallet, webhooks.
5
+ Project-URL: Homepage, https://eveses.com
6
+ Project-URL: Source, https://github.com/evesescom/python-sdk
7
+ Author: Eveses
8
+ License: MIT
9
+ Keywords: activations,api,eveses,sdk,sms
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: requests>=2.28
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # eveses (Python SDK)
28
+
29
+ Official Python SDK for the [Eveses](https://eveses.com) developer API.
30
+ Activations, wallet, catalog (countries / services / pricing), and webhook signature verification.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install eveses
36
+ ```
37
+
38
+ Requires Python 3.9+ and `requests`.
39
+
40
+ ## Quickstart
41
+
42
+ ```python
43
+ import os
44
+ from eveses import Eveses
45
+
46
+ client = Eveses(api_key=os.environ["EVESES_API_KEY"])
47
+
48
+ order = client.activations.create(
49
+ country="ua",
50
+ service="telegram",
51
+ idempotency_key="my-uuid",
52
+ )
53
+ print(order.order_id, order.phone)
54
+
55
+ wallet = client.wallet.balance()
56
+ print(f"{wallet.available_balance / 100} {wallet.currency}")
57
+ ```
58
+
59
+ ## Authentication
60
+
61
+ Every request sends `Authorization: Bearer <api_key>`. Generate an API key from
62
+ your dashboard (`Settings → API keys`). The token is a Sanctum personal-access
63
+ token with `kind=api_key`.
64
+
65
+ ## Activations
66
+
67
+ ```python
68
+ order = client.activations.create(
69
+ country="ua",
70
+ service="telegram",
71
+ mode="activation", # or "rent"
72
+ duration_minutes=60, # rent only
73
+ max_price_cents=100, # optional ceiling
74
+ idempotency_key="my-uuid", # also sent as Idempotency-Key header
75
+ )
76
+
77
+ fresh = client.activations.get(order.order_id)
78
+ sms = client.activations.sms(order.order_id)
79
+ # sms.stored — delivered to us via upstream webhook
80
+ # sms.fresh — pulled from upstream provider on demand
81
+
82
+ client.activations.cancel(order.order_id) # refund-where-supported
83
+ client.activations.finish(order.order_id) # mark consumed
84
+ ```
85
+
86
+ ## Catalog (countries / services / pricing)
87
+
88
+ Read-only metadata for driving order-creation UX. All three calls hit the
89
+ API-key-authenticated `/api/v1/numbers/*` routes, so the same Bearer token
90
+ that creates orders can populate selectors and price tables.
91
+
92
+ ```python
93
+ countries = client.catalog.countries(mode="activation").countries
94
+ services = client.catalog.services(mode="activation", country="ua").services
95
+ pricing = client.catalog.pricing(mode="activation", country="ua", service="telegram")
96
+ # pricing.services[0].durations[0].price_cents → 50
97
+ ```
98
+
99
+ `mode` accepts ``"activation"`` or ``"rent"``. For rentals, pass
100
+ ``duration_minutes=...`` to ``pricing(...)`` to filter to a single duration.
101
+
102
+ ## Webhook verification
103
+
104
+ Eveses signs every outbound webhook delivery with HMAC-SHA256 over
105
+ `f"{timestamp}.{raw_body}"`. Two headers carry the proof:
106
+
107
+ - `X-Eveses-Signature` — e.g. `sha256=abc123…`
108
+ - `X-Eveses-Timestamp` — unix seconds
109
+
110
+ Pass the **raw** request body (bytes or str) — not the parsed JSON. Re-serialising
111
+ through `json.loads` / `json.dumps` reorders keys and breaks the signature.
112
+
113
+ ```python
114
+ # Flask example
115
+ from flask import Flask, request
116
+ from eveses import Webhooks
117
+
118
+ app = Flask(__name__)
119
+ SECRET = os.environ["EVESES_WEBHOOK_SECRET"]
120
+
121
+ @app.post("/eveses-webhook")
122
+ def eveses_webhook():
123
+ raw = request.get_data() # bytes
124
+ if not Webhooks.verify(
125
+ raw,
126
+ request.headers.get("X-Eveses-Signature"),
127
+ SECRET,
128
+ timestamp=request.headers.get("X-Eveses-Timestamp"),
129
+ ):
130
+ return "bad signature", 401
131
+
132
+ payload = request.get_json()
133
+ # handle payload["event"] / payload["data"] …
134
+ return "", 204
135
+ ```
136
+
137
+ A functional alias is also exported:
138
+
139
+ ```python
140
+ from eveses import verify_webhook
141
+ ok = verify_webhook(raw, sig_header, SECRET, timestamp=ts_header)
142
+ ```
143
+
144
+ ## Errors
145
+
146
+ All non-2xx responses raise a typed subclass of `EvesesError`:
147
+
148
+ | Status | Class |
149
+ | --- | --- |
150
+ | 400 / 422 | `EvesesValidationError` (with `.errors`) |
151
+ | 401 | `EvesesAuthError` |
152
+ | 403 | `EvesesForbiddenError` |
153
+ | 404 | `EvesesNotFoundError` |
154
+ | 429 | `EvesesRateLimitError` (only after the 1 auto-retry is exhausted) |
155
+ | 5xx | `EvesesServerError` |
156
+ | other | `EvesesError` |
157
+
158
+ ```python
159
+ from eveses import EvesesValidationError
160
+
161
+ try:
162
+ client.activations.create(country="", service="")
163
+ except EvesesValidationError as e:
164
+ print(e.errors)
165
+ ```
166
+
167
+ ## API surface vs OpenAPI
168
+
169
+ The Eveses public OpenAPI spec exposes the customer-facing endpoints under
170
+ `/api/account/*` (legacy account scope) and `/api/v1/numbers/*` (new versioned
171
+ public API). For API-key consumers (`kind=api_key` Sanctum tokens), the v1
172
+ surface is currently a **thin wrapper** around the same controllers — orders
173
+ and wallet are still served from `/api/account/*`. This SDK targets the
174
+ account-scoped routes, which is where v1 reads & writes terminate today. When
175
+ v1 ships its own activations / wallet routes, you can override the base URL
176
+ without changing call sites; the response shapes are identical.
177
+
178
+ ## Configuration
179
+
180
+ ```python
181
+ client = Eveses(
182
+ api_key="…",
183
+ base_url="https://api.eveses.com", # override per environment
184
+ timeout=30.0,
185
+ session=requests.Session(), # inject for tests / connection pooling
186
+ default_headers={"X-Trace-Id": "t1"},
187
+ user_agent="my-app/1.2.3",
188
+ )
189
+ ```
190
+
191
+ ## Development
192
+
193
+ ```bash
194
+ pip install -e '.[dev]'
195
+ python -m unittest discover -s tests
196
+ ```
197
+
198
+ ## License
199
+
200
+ MIT
@@ -0,0 +1,10 @@
1
+ eveses/__init__.py,sha256=-gp39rS_lH636BO9JEaJ7fR7zC3DkaCi5eE-qYKOAdQ,1573
2
+ eveses/activations.py,sha256=7konMn5UnpoJdgDTco45HxEa-bdPvcR55ciAi_qxthg,4966
3
+ eveses/catalog.py,sha256=2CXnmr_Mz7oC3UqqR31mDvJ_AXH8Jvih5sRQlxnudcY,6988
4
+ eveses/client.py,sha256=mJ1guHZHhcFnynE03XcnQ0L2R0KihS4RsX3D0oXQAGQ,6295
5
+ eveses/exceptions.py,sha256=pnRrlphw9EC5wH-ogttQpdXYIuJbD782Bmwj7pmnoPQ,2483
6
+ eveses/wallet.py,sha256=gRzcBBh7nmHaW6TcVkwHAp-joFMDZaGFBKrb6e0uzaI,1235
7
+ eveses/webhooks.py,sha256=peX0H-FJ8QIsfBCpvibiwa7I2dpBuVGsMBFIyfVMmA4,3284
8
+ eveses-0.1.0.dist-info/METADATA,sha256=L6c1e2v2FTYbRH29JCwbCo-By_vW8MTuagYqZTr1cn4,5957
9
+ eveses-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ eveses-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any