smscode 1.0.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.
- smscode/__init__.py +153 -0
- smscode/_async_transport.py +90 -0
- smscode/_decode.py +74 -0
- smscode/_transport.py +115 -0
- smscode/async_client.py +83 -0
- smscode/client.py +78 -0
- smscode/errors.py +224 -0
- smscode/idempotency.py +22 -0
- smscode/models.py +280 -0
- smscode/money.py +46 -0
- smscode/py.typed +1 -0
- smscode/resources/__init__.py +66 -0
- smscode/resources/balance.py +52 -0
- smscode/resources/catalog.py +359 -0
- smscode/resources/orders.py +625 -0
- smscode/resources/webhook.py +135 -0
- smscode/retry.py +66 -0
- smscode/types.py +35 -0
- smscode/wait.py +125 -0
- smscode/webhook.py +67 -0
- smscode-1.0.0.dist-info/METADATA +169 -0
- smscode-1.0.0.dist-info/RECORD +24 -0
- smscode-1.0.0.dist-info/WHEEL +4 -0
- smscode-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from smscode.errors import InvalidResponseError
|
|
7
|
+
from smscode.models import WebhookConfig, WebhookTestResult
|
|
8
|
+
from smscode.types import ApiResult
|
|
9
|
+
|
|
10
|
+
SyncRequest = Callable[..., ApiResult[Any]]
|
|
11
|
+
AsyncRequest = Callable[..., Awaitable[ApiResult[Any]]]
|
|
12
|
+
ApiPrefix = Literal["/v1", "/v2"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _update_body(
|
|
16
|
+
*,
|
|
17
|
+
webhook_url: str | None = None,
|
|
18
|
+
webhook_secret: str | None = None,
|
|
19
|
+
) -> dict[str, str]:
|
|
20
|
+
body: dict[str, str] = {}
|
|
21
|
+
if webhook_url is not None:
|
|
22
|
+
body["webhook_url"] = webhook_url
|
|
23
|
+
if webhook_secret is not None:
|
|
24
|
+
body["webhook_secret"] = webhook_secret
|
|
25
|
+
return body
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _decode_webhook_config(data: object) -> WebhookConfig:
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
raise InvalidResponseError("The webhook config response is malformed.")
|
|
31
|
+
consecutive = data.get("webhook_consecutive_failures")
|
|
32
|
+
return WebhookConfig(
|
|
33
|
+
webhook_url=data.get("webhook_url") if isinstance(data.get("webhook_url"), str) else None,
|
|
34
|
+
webhook_secret=(
|
|
35
|
+
data.get("webhook_secret") if isinstance(data.get("webhook_secret"), str) else None
|
|
36
|
+
),
|
|
37
|
+
webhook_disabled_at=(
|
|
38
|
+
data.get("webhook_disabled_at")
|
|
39
|
+
if isinstance(data.get("webhook_disabled_at"), str)
|
|
40
|
+
else None
|
|
41
|
+
),
|
|
42
|
+
webhook_disabled_reason=(
|
|
43
|
+
data.get("webhook_disabled_reason")
|
|
44
|
+
if isinstance(data.get("webhook_disabled_reason"), str)
|
|
45
|
+
else None
|
|
46
|
+
),
|
|
47
|
+
webhook_consecutive_failures=consecutive if type(consecutive) is int else 0,
|
|
48
|
+
raw=dict(data),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _decode_webhook_test_result(data: object) -> WebhookTestResult:
|
|
53
|
+
if not isinstance(data, dict) or type(data.get("status_code")) is not int:
|
|
54
|
+
raise InvalidResponseError("The webhook test response is malformed.")
|
|
55
|
+
return WebhookTestResult(status_code=data["status_code"], raw=dict(data))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class WebhookResource:
|
|
59
|
+
def __init__(self, request: SyncRequest, prefix: ApiPrefix) -> None:
|
|
60
|
+
self._request = request
|
|
61
|
+
self._prefix = prefix
|
|
62
|
+
|
|
63
|
+
def get(self) -> WebhookConfig:
|
|
64
|
+
return _decode_webhook_config(self._request("GET", f"{self._prefix}/webhook").data)
|
|
65
|
+
|
|
66
|
+
def update(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
webhook_url: str | None = None,
|
|
70
|
+
webhook_secret: str | None = None,
|
|
71
|
+
) -> WebhookConfig:
|
|
72
|
+
return _decode_webhook_config(
|
|
73
|
+
self._request(
|
|
74
|
+
"PATCH",
|
|
75
|
+
f"{self._prefix}/webhook",
|
|
76
|
+
json=_update_body(webhook_url=webhook_url, webhook_secret=webhook_secret),
|
|
77
|
+
retry=0,
|
|
78
|
+
).data
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def test(self) -> WebhookTestResult:
|
|
82
|
+
return _decode_webhook_test_result(
|
|
83
|
+
self._request("POST", f"{self._prefix}/webhook/test", retry=0).data
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class V2WebhookResource(WebhookResource):
|
|
88
|
+
def __init__(self, request: SyncRequest) -> None:
|
|
89
|
+
super().__init__(request, "/v2")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class V1WebhookResource(WebhookResource):
|
|
93
|
+
def __init__(self, request: SyncRequest) -> None:
|
|
94
|
+
super().__init__(request, "/v1")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AsyncWebhookResource:
|
|
98
|
+
def __init__(self, request: AsyncRequest, prefix: ApiPrefix) -> None:
|
|
99
|
+
self._request = request
|
|
100
|
+
self._prefix = prefix
|
|
101
|
+
|
|
102
|
+
async def get(self) -> WebhookConfig:
|
|
103
|
+
return _decode_webhook_config((await self._request("GET", f"{self._prefix}/webhook")).data)
|
|
104
|
+
|
|
105
|
+
async def update(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
webhook_url: str | None = None,
|
|
109
|
+
webhook_secret: str | None = None,
|
|
110
|
+
) -> WebhookConfig:
|
|
111
|
+
return _decode_webhook_config(
|
|
112
|
+
(
|
|
113
|
+
await self._request(
|
|
114
|
+
"PATCH",
|
|
115
|
+
f"{self._prefix}/webhook",
|
|
116
|
+
json=_update_body(webhook_url=webhook_url, webhook_secret=webhook_secret),
|
|
117
|
+
retry=0,
|
|
118
|
+
)
|
|
119
|
+
).data
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def test(self) -> WebhookTestResult:
|
|
123
|
+
return _decode_webhook_test_result(
|
|
124
|
+
(await self._request("POST", f"{self._prefix}/webhook/test", retry=0)).data
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class AsyncV2WebhookResource(AsyncWebhookResource):
|
|
129
|
+
def __init__(self, request: AsyncRequest) -> None:
|
|
130
|
+
super().__init__(request, "/v2")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AsyncV1WebhookResource(AsyncWebhookResource):
|
|
134
|
+
def __init__(self, request: AsyncRequest) -> None:
|
|
135
|
+
super().__init__(request, "/v1")
|
smscode/retry.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
BACKOFF_BASE_SECONDS = 0.250
|
|
13
|
+
BACKOFF_CAP_SECONDS = 10.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RetryPolicy:
|
|
18
|
+
max_retries: int = 0
|
|
19
|
+
retry_on: Callable[[Exception], bool] = lambda err: False
|
|
20
|
+
retry_after: Callable[[Exception], float | None] | None = None
|
|
21
|
+
delay_seconds: Callable[[int], float] | None = None
|
|
22
|
+
sleep: Callable[[float], None] | None = None
|
|
23
|
+
async_sleep: Callable[[float], Awaitable[None]] | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_delay_seconds(attempt: int) -> float:
|
|
27
|
+
ceiling = min(BACKOFF_CAP_SECONDS, BACKOFF_BASE_SECONDS * 2 ** max(0, attempt - 1))
|
|
28
|
+
return float(random.random() * ceiling)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _retry_delay_seconds(err: Exception, attempt: int, policy: RetryPolicy) -> float:
|
|
32
|
+
retry_after = policy.retry_after(err) if policy.retry_after is not None else None
|
|
33
|
+
if retry_after is not None:
|
|
34
|
+
return max(0.0, retry_after)
|
|
35
|
+
delay = (
|
|
36
|
+
policy.delay_seconds(attempt)
|
|
37
|
+
if policy.delay_seconds is not None
|
|
38
|
+
else default_delay_seconds(attempt)
|
|
39
|
+
)
|
|
40
|
+
return max(0.0, delay)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def with_retry(fn: Callable[[], T], policy: RetryPolicy) -> T:
|
|
44
|
+
sleep = policy.sleep or time.sleep
|
|
45
|
+
max_retries = max(0, policy.max_retries)
|
|
46
|
+
for attempt in range(max_retries + 1):
|
|
47
|
+
try:
|
|
48
|
+
return fn()
|
|
49
|
+
except Exception as err:
|
|
50
|
+
if attempt >= max_retries or not policy.retry_on(err):
|
|
51
|
+
raise
|
|
52
|
+
sleep(_retry_delay_seconds(err, attempt + 1, policy))
|
|
53
|
+
raise RuntimeError("unreachable retry loop")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def async_with_retry(fn: Callable[[], Awaitable[T]], policy: RetryPolicy) -> T:
|
|
57
|
+
sleep = policy.async_sleep or asyncio.sleep
|
|
58
|
+
max_retries = max(0, policy.max_retries)
|
|
59
|
+
for attempt in range(max_retries + 1):
|
|
60
|
+
try:
|
|
61
|
+
return await fn()
|
|
62
|
+
except Exception as err:
|
|
63
|
+
if attempt >= max_retries or not policy.retry_on(err):
|
|
64
|
+
raise
|
|
65
|
+
await sleep(_retry_delay_seconds(err, attempt + 1, policy))
|
|
66
|
+
raise RuntimeError("unreachable retry loop")
|
smscode/types.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Generic, Literal, TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
QueryValue = str | int | float | bool | None
|
|
10
|
+
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
11
|
+
OrderStatus = Literal["ACTIVE", "OTP_RECEIVED", "COMPLETED", "CANCELED", "EXPIRED"]
|
|
12
|
+
SortQuery = Literal[
|
|
13
|
+
"price_asc",
|
|
14
|
+
"price_desc",
|
|
15
|
+
"available_asc",
|
|
16
|
+
"available_desc",
|
|
17
|
+
"name_asc",
|
|
18
|
+
"name_desc",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class RequestOptions:
|
|
24
|
+
params: Mapping[str, QueryValue] | None = None
|
|
25
|
+
json: Any | None = None
|
|
26
|
+
headers: Mapping[str, str] | None = None
|
|
27
|
+
retry: int | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ApiResult(Generic[T]):
|
|
32
|
+
data: T
|
|
33
|
+
meta: dict[str, Any] | None
|
|
34
|
+
request_id: str | None
|
|
35
|
+
status: int
|
smscode/wait.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from smscode.errors import OrderTerminalError, OtpTimeoutError, RateLimitError
|
|
10
|
+
|
|
11
|
+
TERMINAL_STATUSES = {"COMPLETED", "CANCELED", "EXPIRED"}
|
|
12
|
+
DEFAULT_POLL_INTERVAL_MS = 3000
|
|
13
|
+
DEFAULT_TIMEOUT_MS = 20 * 60 * 1000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class OtpResult:
|
|
18
|
+
otp_code: str
|
|
19
|
+
status: str
|
|
20
|
+
order: Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def wait_for_otp(
|
|
24
|
+
poll_order: Callable[[], Any],
|
|
25
|
+
*,
|
|
26
|
+
after_code: str | None = None,
|
|
27
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
28
|
+
poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
|
|
29
|
+
sleep: Callable[[float], None] | None = None,
|
|
30
|
+
now: Callable[[], float] | None = None,
|
|
31
|
+
) -> OtpResult:
|
|
32
|
+
sleep_fn = sleep or time.sleep
|
|
33
|
+
now_fn = now or (lambda: time.monotonic() * 1000)
|
|
34
|
+
deadline = now_fn() + timeout_ms
|
|
35
|
+
|
|
36
|
+
while True:
|
|
37
|
+
try:
|
|
38
|
+
order = poll_order()
|
|
39
|
+
except RateLimitError as err:
|
|
40
|
+
delay_seconds = (
|
|
41
|
+
err.retry_after_seconds
|
|
42
|
+
if err.retry_after_seconds is not None
|
|
43
|
+
else poll_interval_ms / 1000
|
|
44
|
+
)
|
|
45
|
+
if now_fn() + delay_seconds * 1000 > deadline:
|
|
46
|
+
raise OtpTimeoutError("Timed out waiting for OTP.") from err
|
|
47
|
+
sleep_fn(delay_seconds)
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
result = _result_or_error(order, after_code=after_code)
|
|
51
|
+
if result is not None:
|
|
52
|
+
return result
|
|
53
|
+
status = _status(order)
|
|
54
|
+
if status in TERMINAL_STATUSES:
|
|
55
|
+
raise OrderTerminalError(f"Order reached terminal status {status} before OTP arrived.")
|
|
56
|
+
if now_fn() + poll_interval_ms > deadline:
|
|
57
|
+
raise OtpTimeoutError("Timed out waiting for OTP.")
|
|
58
|
+
sleep_fn(poll_interval_ms / 1000)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def async_wait_for_otp(
|
|
62
|
+
poll_order: Callable[[], Awaitable[Any]],
|
|
63
|
+
*,
|
|
64
|
+
after_code: str | None = None,
|
|
65
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
66
|
+
poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
|
|
67
|
+
sleep: Callable[[float], Awaitable[None]] | None = None,
|
|
68
|
+
now: Callable[[], float] | None = None,
|
|
69
|
+
) -> OtpResult:
|
|
70
|
+
sleep_fn = sleep or asyncio.sleep
|
|
71
|
+
now_fn = now or (lambda: time.monotonic() * 1000)
|
|
72
|
+
deadline = now_fn() + timeout_ms
|
|
73
|
+
|
|
74
|
+
while True:
|
|
75
|
+
try:
|
|
76
|
+
order = await poll_order()
|
|
77
|
+
except RateLimitError as err:
|
|
78
|
+
delay_seconds = (
|
|
79
|
+
err.retry_after_seconds
|
|
80
|
+
if err.retry_after_seconds is not None
|
|
81
|
+
else poll_interval_ms / 1000
|
|
82
|
+
)
|
|
83
|
+
if now_fn() + delay_seconds * 1000 > deadline:
|
|
84
|
+
raise OtpTimeoutError("Timed out waiting for OTP.") from err
|
|
85
|
+
await sleep_fn(delay_seconds)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
result = _result_or_error(order, after_code=after_code)
|
|
89
|
+
if result is not None:
|
|
90
|
+
return result
|
|
91
|
+
status = _status(order)
|
|
92
|
+
if status in TERMINAL_STATUSES:
|
|
93
|
+
raise OrderTerminalError(f"Order reached terminal status {status} before OTP arrived.")
|
|
94
|
+
if now_fn() + poll_interval_ms > deadline:
|
|
95
|
+
raise OtpTimeoutError("Timed out waiting for OTP.")
|
|
96
|
+
await sleep_fn(poll_interval_ms / 1000)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _result_or_error(order: Any, *, after_code: str | None) -> OtpResult | None:
|
|
100
|
+
code = _otp_code(order)
|
|
101
|
+
if code is not None and code != "" and code != after_code:
|
|
102
|
+
return OtpResult(otp_code=code, status=_status(order), order=order)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _otp_code(order: Any) -> str | None:
|
|
107
|
+
value = _field(order, "otp_code")
|
|
108
|
+
return value if isinstance(value, str) else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _status(order: Any) -> str:
|
|
112
|
+
value = _field(order, "status")
|
|
113
|
+
return value if isinstance(value, str) else ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _field(order: Any, name: str) -> object:
|
|
117
|
+
if isinstance(order, Mapping):
|
|
118
|
+
return order.get(name)
|
|
119
|
+
value = getattr(order, name, None)
|
|
120
|
+
if value is not None:
|
|
121
|
+
return value
|
|
122
|
+
getter = getattr(order, "get", None)
|
|
123
|
+
if callable(getter):
|
|
124
|
+
return getter(name)
|
|
125
|
+
return None
|
smscode/webhook.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any, TypeAlias
|
|
8
|
+
|
|
9
|
+
WebhookEvent: TypeAlias = dict[str, Any]
|
|
10
|
+
|
|
11
|
+
WEBHOOK_EVENTS = {
|
|
12
|
+
"order.otp_received",
|
|
13
|
+
"order.completed",
|
|
14
|
+
"order.expired",
|
|
15
|
+
"order.canceled",
|
|
16
|
+
"webhook.test",
|
|
17
|
+
}
|
|
18
|
+
SIGNATURE_PREFIX = "sha256="
|
|
19
|
+
SHA256_BYTES = 32
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_webhook_signature(raw_body: bytes | str, signature_header: str, secret: str) -> bool:
|
|
23
|
+
if not isinstance(signature_header, str) or not signature_header.startswith(SIGNATURE_PREFIX):
|
|
24
|
+
return False
|
|
25
|
+
digest_hex = signature_header[len(SIGNATURE_PREFIX) :]
|
|
26
|
+
try:
|
|
27
|
+
provided = bytes.fromhex(digest_hex)
|
|
28
|
+
except ValueError:
|
|
29
|
+
return False
|
|
30
|
+
if len(provided) != SHA256_BYTES:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
expected = hmac.new(_to_bytes(secret), _to_bytes(raw_body), hashlib.sha256).digest()
|
|
34
|
+
return hmac.compare_digest(expected, provided)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_webhook_event(raw_body: bytes | str | object) -> WebhookEvent:
|
|
38
|
+
value = raw_body
|
|
39
|
+
if isinstance(raw_body, bytes):
|
|
40
|
+
value = _json_loads(raw_body.decode("utf-8"))
|
|
41
|
+
elif isinstance(raw_body, str):
|
|
42
|
+
value = _json_loads(raw_body)
|
|
43
|
+
|
|
44
|
+
if not isinstance(value, Mapping):
|
|
45
|
+
raise TypeError("Webhook payload is not a JSON object.")
|
|
46
|
+
event = value.get("event")
|
|
47
|
+
if not isinstance(event, str) or event not in WEBHOOK_EVENTS:
|
|
48
|
+
raise TypeError(f"Webhook payload has an unknown event type: {event!r}.")
|
|
49
|
+
return dict(value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_webhook_event(value: object) -> bool:
|
|
53
|
+
if not isinstance(value, Mapping):
|
|
54
|
+
return False
|
|
55
|
+
event = value.get("event")
|
|
56
|
+
return isinstance(event, str) and event in WEBHOOK_EVENTS
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _json_loads(raw: str) -> object:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(raw)
|
|
62
|
+
except json.JSONDecodeError as err:
|
|
63
|
+
raise TypeError("Webhook payload is not valid JSON.") from err
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _to_bytes(value: bytes | str) -> bytes:
|
|
67
|
+
return value if isinstance(value, bytes) else value.encode("utf-8")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smscode
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the SMSCode virtual number API.
|
|
5
|
+
Project-URL: Homepage, https://smscode.gg
|
|
6
|
+
Project-URL: Documentation, https://smscode.gg/docs/ai.md
|
|
7
|
+
Project-URL: Repository, https://github.com/smscode-gg/sdks
|
|
8
|
+
Project-URL: Issues, https://github.com/smscode-gg/sdks/issues
|
|
9
|
+
Author-email: SMSCode <dev@smscode.gg>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: 2fa,api-client,online-sms,otp,phone-verification,python,receive-sms-online,sdk,sms,sms-otp,sms-verification,smscode,temporary-phone-number,virtual-number,virtual-phone-number
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx<1,>=0.27
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# smscode
|
|
27
|
+
|
|
28
|
+
Official Python SDK for the SMSCode virtual-number API.
|
|
29
|
+
|
|
30
|
+
Use it to rent temporary phone numbers, receive SMS OTP verification codes, and
|
|
31
|
+
manage order lifecycle from Python services, bots, and automations.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install smscode
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python 3.10+.
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
`SmscodeClient` uses the USD-native `/v2` API by default. Money values are typed
|
|
44
|
+
objects with the exact IDR ledger amount preserved as `canonical_amount`.
|
|
45
|
+
|
|
46
|
+
```py
|
|
47
|
+
import os
|
|
48
|
+
|
|
49
|
+
from smscode import OtpTimeoutError, OrderTerminalError, SmscodeClient
|
|
50
|
+
|
|
51
|
+
client = SmscodeClient(token=os.environ["SMSCODE_TOKEN"])
|
|
52
|
+
|
|
53
|
+
body = {
|
|
54
|
+
"catalog_product_id": int(os.environ["SMSCODE_CATALOG_PRODUCT_ID"]),
|
|
55
|
+
"max_price": "0.50", # /v2 uses a USD decimal string, never a float
|
|
56
|
+
"quantity": 1,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
with client:
|
|
60
|
+
created = client.orders.create(body)
|
|
61
|
+
order = created.orders[0]
|
|
62
|
+
order_id = int(order["id"])
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
otp = client.orders.wait_for_otp(order_id, timeout_ms=120_000)
|
|
66
|
+
print("OTP:", otp.otp_code)
|
|
67
|
+
# Submit otp.otp_code in your target app here.
|
|
68
|
+
client.orders.finish(order_id)
|
|
69
|
+
except (OtpTimeoutError, OrderTerminalError):
|
|
70
|
+
# No OTP evidence arrived. Cancel remains available only in that case.
|
|
71
|
+
client.orders.cancel(order_id)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Async client
|
|
75
|
+
|
|
76
|
+
The async client has the same surface and uses `httpx.AsyncClient` internally.
|
|
77
|
+
|
|
78
|
+
```py
|
|
79
|
+
import os
|
|
80
|
+
|
|
81
|
+
from smscode import AsyncSmscodeClient
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def main() -> None:
|
|
85
|
+
async with AsyncSmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client:
|
|
86
|
+
balance = await client.balance.get()
|
|
87
|
+
print(balance.balance.amount, balance.balance.currency)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Resend and wait for a new OTP
|
|
91
|
+
|
|
92
|
+
`finish` does not require a new OTP after resend; the order is finishable once it
|
|
93
|
+
has OTP evidence. If your integration needs to wait for a different post-resend
|
|
94
|
+
code, pass the previous code as `after_code`.
|
|
95
|
+
|
|
96
|
+
```py
|
|
97
|
+
first = client.orders.wait_for_otp(order_id)
|
|
98
|
+
|
|
99
|
+
client.orders.resend(order_id)
|
|
100
|
+
|
|
101
|
+
second = client.orders.wait_for_otp(
|
|
102
|
+
order_id,
|
|
103
|
+
after_code=first.otp_code,
|
|
104
|
+
timeout_ms=120_000,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
print("new OTP:", second.otp_code)
|
|
108
|
+
# Submit second.otp_code in your target app here, then finish.
|
|
109
|
+
client.orders.finish(order_id)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
If the provider sends the same digits again, code-based polling cannot
|
|
113
|
+
distinguish it from the previous OTP.
|
|
114
|
+
|
|
115
|
+
## Idempotent order create
|
|
116
|
+
|
|
117
|
+
Order create is money-sensitive. The SDK resolves an idempotency key before the
|
|
118
|
+
request, sends it as `idempotency-key`, and attaches it to create errors.
|
|
119
|
+
|
|
120
|
+
```py
|
|
121
|
+
from smscode import SmscodeError
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
created = client.orders.create(body)
|
|
125
|
+
except SmscodeError as err:
|
|
126
|
+
if err.idempotency_key is None:
|
|
127
|
+
raise
|
|
128
|
+
# Retry the exact same body with the same key. Never mint a fresh key for
|
|
129
|
+
# the same attempted create.
|
|
130
|
+
created = client.orders.create(body, idempotency_key=err.idempotency_key)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Webhooks
|
|
134
|
+
|
|
135
|
+
Verify webhook signatures against the raw request body before parsing JSON.
|
|
136
|
+
|
|
137
|
+
```py
|
|
138
|
+
from smscode import parse_webhook_event, verify_webhook_signature
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def handle_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> int:
|
|
142
|
+
if not verify_webhook_signature(raw_body, signature_header or "", secret):
|
|
143
|
+
return 401
|
|
144
|
+
|
|
145
|
+
event = parse_webhook_event(raw_body)
|
|
146
|
+
if event["event"] == "order.otp_received":
|
|
147
|
+
print(event["data"]["otp_code"])
|
|
148
|
+
return 204
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## `/v1` namespace
|
|
152
|
+
|
|
153
|
+
Use `.v1` only when you intentionally want legacy IDR-only shapes.
|
|
154
|
+
|
|
155
|
+
```py
|
|
156
|
+
with SmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client:
|
|
157
|
+
balance_v2 = client.balance.get()
|
|
158
|
+
balance_v1 = client.v1.balance.get()
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Error handling
|
|
162
|
+
|
|
163
|
+
Every API error is a typed `SmscodeError` subclass. Branch on the class or
|
|
164
|
+
`err.code`, not on `err.message`. `RateLimitError` and retryable server errors
|
|
165
|
+
carry `retry_after_seconds` when the API sends `Retry-After`.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
smscode/__init__.py,sha256=MJdQeFt3Dwg01m85xsyV0lUHTk9Bq1jjUXbJEVALicE,3402
|
|
2
|
+
smscode/_async_transport.py,sha256=XyCwuXwthvQ2J-PTLJT71fIb8zI9MatxWNzJh7xqyjU,3130
|
|
3
|
+
smscode/_decode.py,sha256=gitDcscQmgBoTgaNmzPK-d622Xp0cfdpbNOw_ccGFcM,2446
|
|
4
|
+
smscode/_transport.py,sha256=NOIEY8IxEO5aREGllVXwShh60pzy6MgJphw6-CY-s1Q,3762
|
|
5
|
+
smscode/async_client.py,sha256=VZ4n9kFi7TNSxsxPZuUNnuuAADgmJ68Ja29np-o7cno,2611
|
|
6
|
+
smscode/client.py,sha256=szU5GIXZPYkC67nR8mWuTuopMU8UyZka_dA-K5TPmFw,2399
|
|
7
|
+
smscode/errors.py,sha256=1tZrdYdZPvAp6Xxh8qe2E9TBcLCSQCNIf-U58c8J7TA,6286
|
|
8
|
+
smscode/idempotency.py,sha256=e6AfamexMZguo9IHlrnHkgv5YCsp816OHGSy39z7nm0,620
|
|
9
|
+
smscode/models.py,sha256=DWuyW139XIgDA5W6tbQ3LIeW5UNWPdFLkr-52CSccT0,6313
|
|
10
|
+
smscode/money.py,sha256=cn1LvnHjqTTQd3mj5weBnxz93OgdRzd63b_99Gr9q2A,1224
|
|
11
|
+
smscode/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
12
|
+
smscode/retry.py,sha256=UOzsAt4LAn3zmf_EmNEErjwc4B2ngnrO4yfYWXqbNmM,2207
|
|
13
|
+
smscode/types.py,sha256=oy-sVPrAzUn3uhKLqGQ2uL5bvU4H68lz6VbpcMf8e9o,843
|
|
14
|
+
smscode/wait.py,sha256=DO2SpB7Jkj7wHArmwwFvJkkLeApAWYxkMqRS1disvLw,4076
|
|
15
|
+
smscode/webhook.py,sha256=8RfPzsk5J-dy4YPt2t0a-e9nrO7Vj-ynEJKgVlKr7Eo,2019
|
|
16
|
+
smscode/resources/__init__.py,sha256=zOaE3bBSEqvE4_GgR3cO_hwUfJGYl9FDgkqUt1yc6Kc,1552
|
|
17
|
+
smscode/resources/balance.py,sha256=TZmR-PkKzqnVP4yX30s25kVOU-8_h7y-8CUVItCN6-A,1486
|
|
18
|
+
smscode/resources/catalog.py,sha256=uXoR_eQlRdU9nO49z7jOReRMV-iNYouur3Rk1hWRS6g,10922
|
|
19
|
+
smscode/resources/orders.py,sha256=ipnTkU1MNE93zFD4NPKQHc44U-0nQVvQfbR5qS10BHM,20716
|
|
20
|
+
smscode/resources/webhook.py,sha256=sDheSbx0soO_Pl72zwuVeq59eIcOukdz5vPNwmD1jWg,4450
|
|
21
|
+
smscode-1.0.0.dist-info/METADATA,sha256=xazYDW4-q0iqLKPFbSz1nbGJpuoKHltykjvKrvhPM-4,4998
|
|
22
|
+
smscode-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
23
|
+
smscode-1.0.0.dist-info/licenses/LICENSE,sha256=yu5mpVULGeeXNzlzQaxHSgF-lh5sjI_iTktvwfghZlE,1064
|
|
24
|
+
smscode-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SMSCode
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|