Skip to content

Commit 172669e

Browse files
committed
chore: update @smscode/sdk (generated from the SMSCode monorepo)
1 parent 1ca219e commit 172669e

79 files changed

Lines changed: 5893 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ concurrency:
1414
cancel-in-progress: true
1515

1616
jobs:
17-
build-test:
17+
typescript-sdk:
1818
runs-on: ubuntu-latest
1919
defaults:
2020
run:
@@ -34,3 +34,27 @@ jobs:
3434
- run: bun run test
3535
- run: bun run lint
3636
- run: npm pack --dry-run
37+
38+
python-sdk:
39+
runs-on: ubuntu-latest
40+
strategy:
41+
matrix:
42+
python-version: ["3.10", "3.11", "3.12", "3.13"]
43+
defaults:
44+
run:
45+
working-directory: packages/sdk-py
46+
steps:
47+
- uses: actions/checkout@v4
48+
- uses: actions/setup-python@v5
49+
with:
50+
python-version: ${{ matrix.python-version }}
51+
- uses: astral-sh/setup-uv@v5
52+
- run: uv sync --all-groups
53+
- run: uv run ruff format --check .
54+
- run: uv run ruff check .
55+
- run: uv run mypy src tests
56+
- run: uv run pytest
57+
- run: uv run python ../../scripts/check-python-contract-fresh.py
58+
- run: uv run python -m build
59+
- run: uv run twine check --strict dist/*
60+
- run: uv run pytest tests/test_package_install.py -q

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Official client SDKs for the [SMSCode](https://smscode.gg) virtual-number API
55
## Packages
66

77
- [`@smscode/sdk`](packages/sdk-js) — TypeScript/JavaScript SDK. Install with `bun add @smscode/sdk` (or `npm i @smscode/sdk`).
8+
- [`smscode`](packages/sdk-py) — Python SDK. Install with `pip install smscode`.
89

910
## Documentation
1011

docs/ai.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SMSCode for AI Agents
22

3-
Compact integration guidance for coding agents building on the SMSCode virtual-number API. Pair this with the OpenAPI contract and the [`@smscode/sdk`](https://github.com/smscode-gg/sdks) TypeScript/JavaScript client.
3+
Compact integration guidance for coding agents building on the SMSCode virtual-number API. Pair this with the OpenAPI contract and the official SDKs in [`smscode-gg/sdks`](https://github.com/smscode-gg/sdks): `@smscode/sdk` for TypeScript/JavaScript and `smscode` for Python.
44

55
- **OpenAPI contract (source of truth):** [`openapi.yaml`](https://smscode.gg/openapi.yaml) — hand-authored OpenAPI 3.1; every request/response shape lives here. Rendered for humans at <https://smscode.gg/docs>.
66
- **Base URL:** `https://api.smscode.gg`
@@ -50,10 +50,18 @@ In the SDK each code maps to a typed subclass of `SmscodeError` (e.g. `RateLimit
5050

5151
## Install the SDK
5252

53+
TypeScript/JavaScript:
54+
5355
```bash
5456
bun add @smscode/sdk # or: npm i @smscode/sdk
5557
```
5658

59+
Python:
60+
61+
```bash
62+
pip install smscode
63+
```
64+
5765
## Recipe: create → waitForOtp → finish (cancel only on no-OTP)
5866

5967
```ts
@@ -148,3 +156,50 @@ if (event.event === "order.otp_received") {
148156
console.log(event.data.otp_code);
149157
}
150158
```
159+
160+
## Python SDK recipe
161+
162+
Python uses snake_case names but follows the same lifecycle and idempotency rules:
163+
164+
```py
165+
import os
166+
167+
from smscode import OtpTimeoutError, OrderTerminalError, SmscodeClient
168+
169+
client = SmscodeClient(token=os.environ["SMSCODE_TOKEN"])
170+
171+
body = {
172+
"catalog_product_id": int(os.environ["SMSCODE_CATALOG_PRODUCT_ID"]),
173+
"max_price": "0.50", # /v2 USD decimal string
174+
"quantity": 1,
175+
}
176+
177+
with client:
178+
created = client.orders.create(body)
179+
order_id = int(created.orders[0]["id"])
180+
181+
try:
182+
otp = client.orders.wait_for_otp(order_id, timeout_ms=120_000)
183+
# Submit otp.otp_code in your target app here.
184+
client.orders.finish(order_id)
185+
except (OtpTimeoutError, OrderTerminalError):
186+
client.orders.cancel(order_id)
187+
```
188+
189+
For resend flows, preserve the previous code:
190+
191+
```py
192+
first = client.orders.wait_for_otp(order_id)
193+
client.orders.resend(order_id)
194+
second = client.orders.wait_for_otp(order_id, after_code=first.otp_code)
195+
```
196+
197+
Verify Python webhook signatures with raw bytes:
198+
199+
```py
200+
from smscode import parse_webhook_event, verify_webhook_signature
201+
202+
if not verify_webhook_signature(raw_body, signature_header or "", secret):
203+
return 401
204+
event = parse_webhook_event(raw_body)
205+
```

packages/sdk-py/.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
dist/
2+
build/
3+
.venv/
4+
.pytest_cache/
5+
.mypy_cache/
6+
.ruff_cache/
7+
*.egg-info/
8+
__pycache__/
9+
*.pyc

packages/sdk-py/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

packages/sdk-py/README.md

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# smscode
2+
3+
Official Python SDK for the SMSCode virtual-number API.
4+
5+
Use it to rent temporary phone numbers, receive SMS OTP verification codes, and
6+
manage order lifecycle from Python services, bots, and automations.
7+
8+
## Install
9+
10+
```bash
11+
pip install smscode
12+
```
13+
14+
Requires Python 3.10+.
15+
16+
## Quick start
17+
18+
`SmscodeClient` uses the USD-native `/v2` API by default. Money values are typed
19+
objects with the exact IDR ledger amount preserved as `canonical_amount`.
20+
21+
```py
22+
import os
23+
24+
from smscode import OtpTimeoutError, OrderTerminalError, SmscodeClient
25+
26+
client = SmscodeClient(token=os.environ["SMSCODE_TOKEN"])
27+
28+
body = {
29+
"catalog_product_id": int(os.environ["SMSCODE_CATALOG_PRODUCT_ID"]),
30+
"max_price": "0.50", # /v2 uses a USD decimal string, never a float
31+
"quantity": 1,
32+
}
33+
34+
with client:
35+
created = client.orders.create(body)
36+
order = created.orders[0]
37+
order_id = int(order["id"])
38+
39+
try:
40+
otp = client.orders.wait_for_otp(order_id, timeout_ms=120_000)
41+
print("OTP:", otp.otp_code)
42+
# Submit otp.otp_code in your target app here.
43+
client.orders.finish(order_id)
44+
except (OtpTimeoutError, OrderTerminalError):
45+
# No OTP evidence arrived. Cancel remains available only in that case.
46+
client.orders.cancel(order_id)
47+
```
48+
49+
## Async client
50+
51+
The async client has the same surface and uses `httpx.AsyncClient` internally.
52+
53+
```py
54+
import os
55+
56+
from smscode import AsyncSmscodeClient
57+
58+
59+
async def main() -> None:
60+
async with AsyncSmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client:
61+
balance = await client.balance.get()
62+
print(balance.balance.amount, balance.balance.currency)
63+
```
64+
65+
## Resend and wait for a new OTP
66+
67+
`finish` does not require a new OTP after resend; the order is finishable once it
68+
has OTP evidence. If your integration needs to wait for a different post-resend
69+
code, pass the previous code as `after_code`.
70+
71+
```py
72+
first = client.orders.wait_for_otp(order_id)
73+
74+
client.orders.resend(order_id)
75+
76+
second = client.orders.wait_for_otp(
77+
order_id,
78+
after_code=first.otp_code,
79+
timeout_ms=120_000,
80+
)
81+
82+
print("new OTP:", second.otp_code)
83+
# Submit second.otp_code in your target app here, then finish.
84+
client.orders.finish(order_id)
85+
```
86+
87+
If the provider sends the same digits again, code-based polling cannot
88+
distinguish it from the previous OTP.
89+
90+
## Idempotent order create
91+
92+
Order create is money-sensitive. The SDK resolves an idempotency key before the
93+
request, sends it as `idempotency-key`, and attaches it to create errors.
94+
95+
```py
96+
from smscode import SmscodeError
97+
98+
try:
99+
created = client.orders.create(body)
100+
except SmscodeError as err:
101+
if err.idempotency_key is None:
102+
raise
103+
# Retry the exact same body with the same key. Never mint a fresh key for
104+
# the same attempted create.
105+
created = client.orders.create(body, idempotency_key=err.idempotency_key)
106+
```
107+
108+
## Webhooks
109+
110+
Verify webhook signatures against the raw request body before parsing JSON.
111+
112+
```py
113+
from smscode import parse_webhook_event, verify_webhook_signature
114+
115+
116+
def handle_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> int:
117+
if not verify_webhook_signature(raw_body, signature_header or "", secret):
118+
return 401
119+
120+
event = parse_webhook_event(raw_body)
121+
if event["event"] == "order.otp_received":
122+
print(event["data"]["otp_code"])
123+
return 204
124+
```
125+
126+
## `/v1` namespace
127+
128+
Use `.v1` only when you intentionally want legacy IDR-only shapes.
129+
130+
```py
131+
with SmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client:
132+
balance_v2 = client.balance.get()
133+
balance_v1 = client.v1.balance.get()
134+
```
135+
136+
## Error handling
137+
138+
Every API error is a typed `SmscodeError` subclass. Branch on the class or
139+
`err.code`, not on `err.message`. `RateLimitError` and retryable server errors
140+
carry `retry_after_seconds` when the API sends `Retry-After`.
141+
142+
## License
143+
144+
MIT
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import os
2+
3+
from smscode import AsyncSmscodeClient, OrderTerminalError, OtpTimeoutError
4+
5+
6+
async def main() -> None:
7+
body = {
8+
"catalog_product_id": int(os.environ["SMSCODE_CATALOG_PRODUCT_ID"]),
9+
"max_price": "0.50",
10+
"quantity": 1,
11+
}
12+
13+
async with AsyncSmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client:
14+
created = await client.orders.create(body)
15+
order_id = int(created.orders[0]["id"])
16+
17+
try:
18+
otp = await client.orders.wait_for_otp(order_id, timeout_ms=120_000)
19+
print("OTP:", otp.otp_code)
20+
# Submit otp.otp_code in your target app here.
21+
await client.orders.finish(order_id)
22+
except (OtpTimeoutError, OrderTerminalError):
23+
await client.orders.cancel(order_id)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import os
2+
3+
from smscode import OrderTerminalError, OtpTimeoutError, SmscodeClient
4+
5+
6+
def main() -> None:
7+
body = {
8+
"catalog_product_id": int(os.environ["SMSCODE_CATALOG_PRODUCT_ID"]),
9+
"max_price": "0.50",
10+
"quantity": 1,
11+
}
12+
13+
with SmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client:
14+
created = client.orders.create(body)
15+
order_id = int(created.orders[0]["id"])
16+
17+
try:
18+
otp = client.orders.wait_for_otp(order_id, timeout_ms=120_000)
19+
print("OTP:", otp.otp_code)
20+
# Submit otp.otp_code in your target app here.
21+
client.orders.finish(order_id)
22+
except (OtpTimeoutError, OrderTerminalError):
23+
client.orders.cancel(order_id)
24+
25+
26+
if __name__ == "__main__":
27+
main()

packages/sdk-py/examples/smoke.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from smscode import (
2+
AsyncSmscodeClient,
3+
SmscodeClient,
4+
is_webhook_event,
5+
parse_webhook_event,
6+
verify_webhook_signature,
7+
)
8+
9+
10+
def main() -> None:
11+
assert SmscodeClient.__name__ == "SmscodeClient"
12+
assert AsyncSmscodeClient.__name__ == "AsyncSmscodeClient"
13+
assert callable(verify_webhook_signature)
14+
event = parse_webhook_event('{"event":"webhook.test","data":{"message":"ok"}}')
15+
assert is_webhook_event(event)
16+
17+
18+
if __name__ == "__main__":
19+
main()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from smscode import parse_webhook_event, verify_webhook_signature
2+
3+
4+
def handle_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> tuple[int, str]:
5+
if not verify_webhook_signature(raw_body, signature_header or "", secret):
6+
return 401, "bad signature"
7+
8+
event = parse_webhook_event(raw_body)
9+
if event["event"] == "order.otp_received":
10+
otp_code = event["data"].get("otp_code")
11+
print("OTP:", otp_code)
12+
return 204, "ok"

0 commit comments

Comments
 (0)