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 +66 -0
- eveses/activations.py +157 -0
- eveses/catalog.py +216 -0
- eveses/client.py +198 -0
- eveses/exceptions.py +76 -0
- eveses/wallet.py +47 -0
- eveses/webhooks.py +101 -0
- eveses-0.1.0.dist-info/METADATA +200 -0
- eveses-0.1.0.dist-info/RECORD +10 -0
- eveses-0.1.0.dist-info/WHEEL +4 -0
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,,
|