Metadata-Version: 2.5
Name: onesms
Version: 0.1.1
Summary: Python client for the 1sms.az SMS API
Project-URL: Homepage, https://github.com/martian56/onesms-sdk
Project-URL: Repository, https://github.com/martian56/onesms-sdk
Project-URL: API Reference, https://1sms.az/api-docs
Author: martian56
License: MIT
License-File: LICENSE
Keywords: 1sms,api,azerbaijan,client,onesms,otp,sms
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Communications
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.24
Description-Content-Type: text/markdown

# onesms

[![PyPI](https://img.shields.io/pypi/v/onesms.svg)](https://pypi.org/project/onesms/)
[![CI](https://github.com/martian56/onesms-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/martian56/onesms-sdk/actions/workflows/ci.yml)
[![Docs](https://img.shields.io/badge/docs-mkdocs--material-teal.svg)](https://martian56.github.io/onesms-sdk/)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

A typed Python client for the [1sms.az](https://1sms.az) SMS API, with both
synchronous and asynchronous interfaces.

Documentation: https://martian56.github.io/onesms-sdk/

1sms.az API reference: https://1sms.az/api-docs

## Features

- Sync (`Client`) and async (`AsyncClient`) clients with the same surface.
- OTP, notification, and advertising sends, plus balance and delivery-status lookups.
- Fully typed responses as frozen dataclasses; ships a `py.typed` marker.
- Automatic retries with exponential backoff and jitter, honoring `Retry-After`.
- Idempotency-key support so retried sends are never duplicated.
- A precise exception hierarchy mapped from the API's HTTP status and error codes.
- Webhook signature verification (HMAC-SHA256) with timestamp tolerance.
- No hard dependencies beyond `httpx`.

## Requirements

- Python 3.10 or newer
- A 1sms.az API key

## Installation

```bash
pip install onesms
```

Or add it to a `uv` project:

```bash
uv add onesms
```

To install the latest unreleased code, point at the repository instead:

```bash
pip install git+https://github.com/martian56/onesms-sdk.git
```

## Quickstart

```python
from onesms import Client

with Client("1sk_your_api_key", sender_name="YourSender") as client:
    result = client.send_otp("994501234567", "Your code is 123456")
    print(result.message_id, result.cost, result.balance)
```

### Async

```python
import asyncio

from onesms import AsyncClient


async def main() -> None:
    async with AsyncClient("1sk_your_api_key") as client:
        result = await client.send_otp("994501234567", "Your code is 123456")
        print(result.message_id)


asyncio.run(main())
```

## Usage

### Check the balance

```python
balance = client.balance()
print(balance.balance)
print(balance.apis.otp, balance.apis.bulk, balance.apis.advertising)
```

### Send an OTP

```python
result = client.send_otp("994501234567", "Your code is 123456")
```

### Send notifications

A single recipient returns per-message ids; two or more recipients are dispatched
as a bulk task and return a `task_id` you can poll.

```python
single = client.send_notification(["994501234567"], "Order shipped")
print(single.message_ids)

bulk = client.send_notification(
    ["994501234567", "994502223344", "994553334455"],
    "Weekend promotion",
)
print(bulk.task_id, bulk.sent_count, bulk.failed_count)
for item in bulk.rejected:
    print(item.number, item.reason)
```

### Send advertising

```python
result = client.send_advertising(["994501234567"], "Big discounts this week")
```

### Look up delivery status

```python
from onesms import DeliveryStatus

status = client.message_status("message-id")
print(status.status_code, status.status_text)

if status.is_final:
    if status.status is DeliveryStatus.DELIVERED:
        print("delivered")
    else:
        print("not delivered:", status.status_text)
```

### Poll a bulk task

```python
from onesms import Channel

task = client.task_status("task-id", channel=Channel.NOTIFICATION)
print(task.sent_count, task.failed_count)
for message in task.messages:
    print(message.phone, message.status_text, message.is_final)
```

## Idempotency

Pass an `idempotency_key` to make a send safe to retry. The API remembers the key
for 24 hours and will not send the same request twice. When a key is supplied, the
client also retries transient network and server errors automatically.

```python
client.send_otp(
    "994501234567",
    "Your code is 123456",
    idempotency_key="order-4821-otp",
)
```

## Retries

`GET` requests and `429 Too Many Requests` responses are always retried. Network
failures and `5x` responses on writes are retried only when an idempotency key is
present, so a send is never silently duplicated. Backoff is exponential with jitter
and respects a `Retry-After` header when the API provides one. Configure the ceiling
with `max_retries`:

```python
client = Client("1sk_your_api_key", max_retries=5, timeout=15.0)
```

## Error handling

Every API failure raises a subclass of `OneSmsError`.

```python
from onesms import (
    Client,
    InsufficientBalanceError,
    OneSmsAPIError,
    OneSmsConnectionError,
    OneSmsValidationError,
    RateLimitError,
)

try:
    client.send_notification(["994501234567"], "Hello")
except OneSmsValidationError as exc:
    print("invalid input:", exc)
except InsufficientBalanceError as exc:
    print("top up:", exc.required, "have:", exc.balance)
except RateLimitError as exc:
    print("retry after:", exc.retry_after)
except OneSmsConnectionError as exc:
    print("network problem:", exc)
except OneSmsAPIError as exc:
    print(exc.status_code, exc.error_code, exc.message)
```

| Exception | Raised for |
| --- | --- |
| `OneSmsValidationError` | Client-side checks before a request is sent |
| `OneSmsConnectionError` | Network failures that could not be retried |
| `BadRequestError` | `400` |
| `AuthenticationError` | `401` |
| `InsufficientBalanceError` | `402` (exposes `required`, `balance`) |
| `PermissionDeniedError` | `403` |
| `NotFoundError` | `404` |
| `ConflictError` | `409` |
| `RateLimitError` | `429` (exposes `retry_after`) |
| `ServerError` | `5xx` (exposes `msm_errno`, `msm_err_text`, `hint`) |
| `OneSmsAPIError` | Base class for any API error |

## Webhooks

1sms.az signs delivery webhooks with an HMAC-SHA256 signature over the request
timestamp and raw body. Verify it with your API secret before trusting the payload.

```python
from onesms import WebhookEvent, verify_signature

signature = request.headers["X-1sms-Signature"]
timestamp = request.headers["X-1sms-Timestamp"]
raw_body = request.get_data()

if not verify_signature("your_api_secret", timestamp, raw_body, signature):
    raise ValueError("invalid signature")

event = WebhookEvent.from_payload(request.get_json())
if event.is_final:
    print(event.message_id, event.status_text)
```

`verify_signature` rejects timestamps outside a five-minute window by default.
Widen it with `tolerance_seconds` if needed.

## Development

```bash
uv sync
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv run pytest
```

## License

[MIT](LICENSE)
