Metadata-Version: 2.5
Name: honkio
Version: 0.1.0
Summary: Official Python SDK for HonkIO: Canadian SMS and email, CASL built in.
Project-URL: Homepage, https://honkio.ca/docs/sdk
Project-URL: Documentation, https://honkio.ca/docs/sdk
Project-URL: Issues, https://honkio.ca/contact
Author: somethingcorporate
License-Expression: MIT
License-File: LICENSE
Keywords: api,canada,casl,email,honkio,sms
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
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 :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# honkio

The official Python SDK for [HonkIO](https://honkio.ca): Canadian SMS and email, with CASL built in. Data is stored in Canada (ca-central-1).

- Python 3.10 or newer; one dependency, [httpx](https://www.python-httpx.org)
- A synchronous client (`Honkio`) and an asynchronous one (`AsyncHonkio`) with the same resources and arguments
- Errors are exceptions; every API error is a subclass of `honkio.ApiError`
- Typed (`py.typed`); responses are plain dicts described by `TypedDict`s, importable from `honkio.types` (`from honkio.types import Message`)
- No automatic retries: a send costs money, so retrying is your call (use an idempotency key)

## Install

```sh
pip install honkio
```

## Quickstart

Create a key in the dashboard under API Keys, Create key (<https://honkio.ca/dashboard/keys>); the full key is shown once, right after you create it. Start with a test key (`mk_test_...`): nothing is delivered or charged.

Send an SMS:

```python
from honkio import Honkio

client = Honkio()  # reads HONKIO_API_KEY

message = client.messages.send(
    from_="+1416XXXXXXX",  # one of your HonkIO numbers
    to="+1613XXXXXXX",  # a Canadian number you hold consent for
    body="Hello from HonkIO!",
    idempotency_key="order-1042",
)
print(message["id"], message["status"])
```

Send an email:

```python
sent = client.emails.send(
    from_="Acme <hello@yourdomain.ca>",  # a verified sending domain
    to="sam@example.com",
    subject="Welcome",
    html="<p>Hello!</p>",
    idempotency_key="welcome-sam",
)
print(sent["id"])
```

The same calls, asynchronously:

```python
import asyncio
from honkio import AsyncHonkio


async def main() -> None:
    async with AsyncHonkio() as client:
        message = await client.messages.send(from_="+1416XXXXXXX", to="+1613XXXXXXX", body="Hi")
        print(message["id"])


asyncio.run(main())
```

`Honkio()` with no argument reads `HONKIO_API_KEY` and raises `InvalidArgumentError` if neither is set. Options: `Honkio(api_key, base_url="https://api.honkio.ca", timeout=30.0, http_client=None)`. Pass your own `httpx.Client` (or `httpx.AsyncClient`) as `http_client` for proxies, mTLS or tests; the SDK then leaves closing it to you, and the timeout is the one you configured on it (`timeout=` applies only to a client the SDK builds). A client the SDK built is closed by `client.close()` (`await client.aclose()`), or by leaving a `with` (`async with`) block.

## Arguments

Every call takes keyword-only arguments named like the API's own (snake_case) fields. `from` is a Python keyword, so it is `from_` and is sent as `from`. A typo is a `TypeError`, not a silently ignored field. Ids come first, positionally: `client.domains.remove("dom_1")`.

An optional argument you leave out is not sent (its default is `honkio.NOT_GIVEN`). Pass `None` to send an explicit JSON `null`, which clears a nullable field: `client.domains.update("dom_1", open_tracking=None)` restores the platform default, `client.templates.update("welcome", html=None)` removes the HTML. In a query string `None` is omitted like `NOT_GIVEN`. Dicts you pass (`variables`, `headers`, template objects) are sent exactly as you wrote them. Query lists repeat the key.

Datetimes: pass a timezone-aware `datetime` (or an ISO 8601 string). It is sent in UTC with a `Z` suffix, so `datetime(2026, 10, 15, 9, 0, tzinfo=ZoneInfo("America/Toronto"))` goes out as `2026-10-15T13:00:00Z`. A naive `datetime` raises `InvalidArgumentError` before anything is sent. The two date-only filters of `messages.list` (`date_from`, `date_to`) and `last_transaction_date` on consents take a `date` or a `YYYY-MM-DD` string and are sent as `YYYY-MM-DD` (a timezone-aware `datetime` there is converted to its UTC date).

## Errors

```python
from honkio import ApiError, UnavailableForLegalReasonsError, UnprocessableEntityError, APITimeoutError

try:
    client.messages.send(from_="+1416XXXXXXX", to="+1613XXXXXXX", body="Hi")
except UnavailableForLegalReasonsError as e:  # HTTP 451
    print(e.code, e.message)  # NO_CONSENT, CONSENT_EXPIRED, OPT_OUT_BLOCKED
except UnprocessableEntityError as e:  # HTTP 422
    print(e.code, e.message)  # NON_CANADIAN_NUMBER, ...
except ApiError as e:
    print(e.status_code, e.code, e.details)
except APITimeoutError:
    ...  # the send may or may not have gone through: retry with the same idempotency key
```

| Exception | When |
|---|---|
| `ApiError` (`.code`, `.message`, `.status_code`, `.details`) | the API answered with an error; subclasses by status: `BadRequestError` 400, `AuthenticationError` 401, `PaymentRequiredError` 402, `PermissionDeniedError` 403, `NotFoundError` 404, `ConflictError` 409, `UnprocessableEntityError` 422, `RateLimitError` 429, `UnavailableForLegalReasonsError` 451, `InternalServerError` 5xx |
| `APIConnectionError`, `APITimeoutError` | no response arrived (DNS, refused connection, 30 s timeout) |
| `InvalidArgumentError` (also a `ValueError`) | refused before anything was sent: an id that is empty, `.` or `..`, a consent call that named both or neither subject, a naive `datetime`, an empty `api_key`, a webhook check with a missing secret |
| `InvalidSignatureError` | a webhook delivery could not be verified |
| `HonkioError` | the base of all of the above |

Every `ApiError` pickles (for task queues and multiprocessing). A 3xx answer (the SDK does not follow redirects) is an `ApiError` whose message and `details["location"]` carry the `Location` header; check `base_url`.

`details` is the API's `details` when it sent one. Otherwise it holds whatever the API sent beside the error envelope, such as `{"attempts_remaining": 2}` on `VERIFICATION_INVALID_CODE`. A body that is not JSON (a proxy error page, say) is `ApiError` with code `application_error`.

The general codes (authentication, permissions, balance, validation, rate limits) and every SMS and email code are listed at <https://honkio.ca/docs/errors>.

## Idempotency

`messages.send`, `emails.send` and `batch.send` take `idempotency_key=`, sent as the `Idempotency-Key` header. Reuse the key when you retry, and a send can never go out or be charged twice.

## Webhooks

Check a delivery's signature before you trust it. Pass the raw request body exactly as received, before any JSON parsing:

```python
from honkio import verify_webhook, InvalidSignatureError


def handle(request):  # a Flask-style request
    try:
        event = verify_webhook(request.get_data(), request.headers, WEBHOOK_SECRET)
    except InvalidSignatureError:
        return "bad signature", 400
    if event["type"] == "message.delivered":
        ...
    return "", 204
```

`verify_webhook(raw_body, headers, secret, *, tolerance_seconds=300)` takes a `str` or `bytes` body and any mapping of headers, or any object with `.items()` such as Flask/Werkzeug `request.headers` (names are matched case-insensitively; a list value uses its first item; a list of tuples is refused with `InvalidArgumentError`). It returns the parsed event, raises `InvalidSignatureError` for anything about the signature, the timestamp (more than 300 seconds old) or the body, and `InvalidArgumentError` for bad input such as a body already parsed into a dict. `client.webhooks.verify(...)` is the same function. On `AsyncHonkio` it is still a plain function, not awaitable: call it without `await`. `honkio.WEBHOOK_EVENTS` lists every event a webhook can subscribe to.

## Resources

| `client.` | Methods |
|---|---|
| `messages` | `send`, `get`, `list` |
| `phone_numbers` | `area_codes`, `search`, `provision`, `list`, `get`, `release` |
| `verify` | `start`, `check`, `get`, `list` |
| `consents` | `create`, `list`, `record_batch`, `check`, `revoke` (the last two take exactly one of `phone_number` or `email_address`) |
| `consent_batches` | `list`, `get` |
| `opt_in_confirmations` | `send`, `list` |
| `webhooks` | `create`, `list`, `get`, `update`, `remove`, `deliveries`, `dead_letters`, `replay`, `discard`, `reactivate`, `rotate_secret`, `verify` |
| `emails` | `send`, `get`, `list`, `update`, `cancel` |
| `emails.received` | `list`, `address`, `set_address_enabled`, `get`, `raw`, `attachment`, `simulate` |
| `batch` | `send` (a list of emails, or `recipients=` for one message to many) |
| `domains` | `create`, `get`, `list`, `update`, `verify`, `remove` |
| `suppressions` | `create`, `list`, `remove` |
| `templates` | `create`, `get`, `list`, `update`, `publish`, `rollback`, `versions`, `remove` |
| `toll_free_verifications` | `options`, `create`, `update`, `submit`, `cancel`, `get`, `list` |
| `business_verification` | `get`, `save`, `submit` |
| `auto_topup` | `get` |
| `topup_cap_requests` | `create`, `list` |

`emails.received.raw` and `emails.received.attachment` return `bytes`. A call that answers 204 (the deletes) returns `None`.

Full API reference: <https://honkio.ca/docs/api>. SDK guide: <https://honkio.ca/docs/sdk>.

## Development

```sh
uv venv && uv pip install -e '.[dev]'
ruff check . && mypy --strict src && pytest
python scripts/generate_types.py   # after web/generated/openapi.json changes
```

Inside the monorepo, the test suite reads `web/generated/openapi.json` (and the API source) and fails when a route or keyword argument the SDK uses is not in the spec, when `WEBHOOK_EVENTS` differs from the API's list, or when `src/honkio/_types.py` is stale. Those tests skip when run from a checkout without the monorepo.

## License

MIT
