eveses 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.
eveses-0.1.0/PKG-INFO ADDED
@@ -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
eveses-0.1.0/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # eveses (Python SDK)
2
+
3
+ Official Python SDK for the [Eveses](https://eveses.com) developer API.
4
+ Activations, wallet, catalog (countries / services / pricing), and webhook signature verification.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install eveses
10
+ ```
11
+
12
+ Requires Python 3.9+ and `requests`.
13
+
14
+ ## Quickstart
15
+
16
+ ```python
17
+ import os
18
+ from eveses import Eveses
19
+
20
+ client = Eveses(api_key=os.environ["EVESES_API_KEY"])
21
+
22
+ order = client.activations.create(
23
+ country="ua",
24
+ service="telegram",
25
+ idempotency_key="my-uuid",
26
+ )
27
+ print(order.order_id, order.phone)
28
+
29
+ wallet = client.wallet.balance()
30
+ print(f"{wallet.available_balance / 100} {wallet.currency}")
31
+ ```
32
+
33
+ ## Authentication
34
+
35
+ Every request sends `Authorization: Bearer <api_key>`. Generate an API key from
36
+ your dashboard (`Settings → API keys`). The token is a Sanctum personal-access
37
+ token with `kind=api_key`.
38
+
39
+ ## Activations
40
+
41
+ ```python
42
+ order = client.activations.create(
43
+ country="ua",
44
+ service="telegram",
45
+ mode="activation", # or "rent"
46
+ duration_minutes=60, # rent only
47
+ max_price_cents=100, # optional ceiling
48
+ idempotency_key="my-uuid", # also sent as Idempotency-Key header
49
+ )
50
+
51
+ fresh = client.activations.get(order.order_id)
52
+ sms = client.activations.sms(order.order_id)
53
+ # sms.stored — delivered to us via upstream webhook
54
+ # sms.fresh — pulled from upstream provider on demand
55
+
56
+ client.activations.cancel(order.order_id) # refund-where-supported
57
+ client.activations.finish(order.order_id) # mark consumed
58
+ ```
59
+
60
+ ## Catalog (countries / services / pricing)
61
+
62
+ Read-only metadata for driving order-creation UX. All three calls hit the
63
+ API-key-authenticated `/api/v1/numbers/*` routes, so the same Bearer token
64
+ that creates orders can populate selectors and price tables.
65
+
66
+ ```python
67
+ countries = client.catalog.countries(mode="activation").countries
68
+ services = client.catalog.services(mode="activation", country="ua").services
69
+ pricing = client.catalog.pricing(mode="activation", country="ua", service="telegram")
70
+ # pricing.services[0].durations[0].price_cents → 50
71
+ ```
72
+
73
+ `mode` accepts ``"activation"`` or ``"rent"``. For rentals, pass
74
+ ``duration_minutes=...`` to ``pricing(...)`` to filter to a single duration.
75
+
76
+ ## Webhook verification
77
+
78
+ Eveses signs every outbound webhook delivery with HMAC-SHA256 over
79
+ `f"{timestamp}.{raw_body}"`. Two headers carry the proof:
80
+
81
+ - `X-Eveses-Signature` — e.g. `sha256=abc123…`
82
+ - `X-Eveses-Timestamp` — unix seconds
83
+
84
+ Pass the **raw** request body (bytes or str) — not the parsed JSON. Re-serialising
85
+ through `json.loads` / `json.dumps` reorders keys and breaks the signature.
86
+
87
+ ```python
88
+ # Flask example
89
+ from flask import Flask, request
90
+ from eveses import Webhooks
91
+
92
+ app = Flask(__name__)
93
+ SECRET = os.environ["EVESES_WEBHOOK_SECRET"]
94
+
95
+ @app.post("/eveses-webhook")
96
+ def eveses_webhook():
97
+ raw = request.get_data() # bytes
98
+ if not Webhooks.verify(
99
+ raw,
100
+ request.headers.get("X-Eveses-Signature"),
101
+ SECRET,
102
+ timestamp=request.headers.get("X-Eveses-Timestamp"),
103
+ ):
104
+ return "bad signature", 401
105
+
106
+ payload = request.get_json()
107
+ # handle payload["event"] / payload["data"] …
108
+ return "", 204
109
+ ```
110
+
111
+ A functional alias is also exported:
112
+
113
+ ```python
114
+ from eveses import verify_webhook
115
+ ok = verify_webhook(raw, sig_header, SECRET, timestamp=ts_header)
116
+ ```
117
+
118
+ ## Errors
119
+
120
+ All non-2xx responses raise a typed subclass of `EvesesError`:
121
+
122
+ | Status | Class |
123
+ | --- | --- |
124
+ | 400 / 422 | `EvesesValidationError` (with `.errors`) |
125
+ | 401 | `EvesesAuthError` |
126
+ | 403 | `EvesesForbiddenError` |
127
+ | 404 | `EvesesNotFoundError` |
128
+ | 429 | `EvesesRateLimitError` (only after the 1 auto-retry is exhausted) |
129
+ | 5xx | `EvesesServerError` |
130
+ | other | `EvesesError` |
131
+
132
+ ```python
133
+ from eveses import EvesesValidationError
134
+
135
+ try:
136
+ client.activations.create(country="", service="")
137
+ except EvesesValidationError as e:
138
+ print(e.errors)
139
+ ```
140
+
141
+ ## API surface vs OpenAPI
142
+
143
+ The Eveses public OpenAPI spec exposes the customer-facing endpoints under
144
+ `/api/account/*` (legacy account scope) and `/api/v1/numbers/*` (new versioned
145
+ public API). For API-key consumers (`kind=api_key` Sanctum tokens), the v1
146
+ surface is currently a **thin wrapper** around the same controllers — orders
147
+ and wallet are still served from `/api/account/*`. This SDK targets the
148
+ account-scoped routes, which is where v1 reads & writes terminate today. When
149
+ v1 ships its own activations / wallet routes, you can override the base URL
150
+ without changing call sites; the response shapes are identical.
151
+
152
+ ## Configuration
153
+
154
+ ```python
155
+ client = Eveses(
156
+ api_key="…",
157
+ base_url="https://api.eveses.com", # override per environment
158
+ timeout=30.0,
159
+ session=requests.Session(), # inject for tests / connection pooling
160
+ default_headers={"X-Trace-Id": "t1"},
161
+ user_agent="my-app/1.2.3",
162
+ )
163
+ ```
164
+
165
+ ## Development
166
+
167
+ ```bash
168
+ pip install -e '.[dev]'
169
+ python -m unittest discover -s tests
170
+ ```
171
+
172
+ ## License
173
+
174
+ MIT
@@ -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
+ ]
@@ -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="")