honkio 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.
- honkio-0.1.0/.gitignore +7 -0
- honkio-0.1.0/CHANGELOG.md +16 -0
- honkio-0.1.0/LICENSE +21 -0
- honkio-0.1.0/PKG-INFO +199 -0
- honkio-0.1.0/README.md +169 -0
- honkio-0.1.0/pyproject.toml +63 -0
- honkio-0.1.0/scripts/generate_types.py +99 -0
- honkio-0.1.0/src/honkio/__init__.py +53 -0
- honkio-0.1.0/src/honkio/_client.py +139 -0
- honkio-0.1.0/src/honkio/_core.py +280 -0
- honkio-0.1.0/src/honkio/_endpoints.py +93 -0
- honkio-0.1.0/src/honkio/_errors.py +101 -0
- honkio-0.1.0/src/honkio/_transport.py +114 -0
- honkio-0.1.0/src/honkio/_types.py +561 -0
- honkio-0.1.0/src/honkio/_version.py +1 -0
- honkio-0.1.0/src/honkio/_webhooks.py +138 -0
- honkio-0.1.0/src/honkio/py.typed +0 -0
- honkio-0.1.0/src/honkio/resources/__init__.py +53 -0
- honkio-0.1.0/src/honkio/resources/auto_topup.py +35 -0
- honkio-0.1.0/src/honkio/resources/batch.py +109 -0
- honkio-0.1.0/src/honkio/resources/business_verification.py +117 -0
- honkio-0.1.0/src/honkio/resources/consent_batches.py +59 -0
- honkio-0.1.0/src/honkio/resources/consents.py +217 -0
- honkio-0.1.0/src/honkio/resources/domains.py +158 -0
- honkio-0.1.0/src/honkio/resources/emails.py +260 -0
- honkio-0.1.0/src/honkio/resources/messages.py +166 -0
- honkio-0.1.0/src/honkio/resources/opt_in_confirmations.py +87 -0
- honkio-0.1.0/src/honkio/resources/phone_numbers.py +133 -0
- honkio-0.1.0/src/honkio/resources/received_emails.py +252 -0
- honkio-0.1.0/src/honkio/resources/suppressions.py +85 -0
- honkio-0.1.0/src/honkio/resources/templates.py +280 -0
- honkio-0.1.0/src/honkio/resources/toll_free_verifications.py +173 -0
- honkio-0.1.0/src/honkio/resources/topup_cap_requests.py +81 -0
- honkio-0.1.0/src/honkio/resources/verify.py +142 -0
- honkio-0.1.0/src/honkio/resources/webhooks.py +271 -0
- honkio-0.1.0/src/honkio/types.py +12 -0
honkio-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Versions are published to PyPI by hand; a version without a date is not on PyPI yet.
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
First release, with the coverage of `@honkio/node` 0.7.0.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Honkio` and `AsyncHonkio` clients over httpx, with the same resources and arguments: messages, phone numbers, verify, consents, consent batches, opt-in confirmations, webhooks, emails (and `emails.received`), batch, domains, suppressions, templates, toll-free verifications, business verification, auto top-up and top-up cap requests.
|
|
12
|
+
- Errors are exceptions: `ApiError` and a subclass per status, `APIConnectionError`, `APITimeoutError`, `InvalidArgumentError`, `InvalidSignatureError`.
|
|
13
|
+
- `verify_webhook` (also `client.webhooks.verify`) and `WEBHOOK_EVENTS`.
|
|
14
|
+
- `TypedDict` response types generated from the OpenAPI snapshot, and `py.typed`.
|
|
15
|
+
- `ENDPOINTS`: every route the SDK calls.
|
|
16
|
+
- `NOT_GIVEN`: optional arguments default to it and are not sent; `None` is sent as JSON `null` in a body.
|
honkio-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 somethingcorporate
|
|
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.
|
honkio-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: honkio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for HonkIO: Canadian SMS and email, CASL built in.
|
|
5
|
+
Project-URL: Homepage, https://honkio.ca/docs/sdk
|
|
6
|
+
Project-URL: Documentation, https://honkio.ca/docs/sdk
|
|
7
|
+
Project-URL: Issues, https://honkio.ca/contact
|
|
8
|
+
Author: somethingcorporate
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,canada,casl,email,honkio,sms
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
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: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx<1,>=0.27
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# honkio
|
|
32
|
+
|
|
33
|
+
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).
|
|
34
|
+
|
|
35
|
+
- Python 3.10 or newer; one dependency, [httpx](https://www.python-httpx.org)
|
|
36
|
+
- A synchronous client (`Honkio`) and an asynchronous one (`AsyncHonkio`) with the same resources and arguments
|
|
37
|
+
- Errors are exceptions; every API error is a subclass of `honkio.ApiError`
|
|
38
|
+
- Typed (`py.typed`); responses are plain dicts described by `TypedDict`s, importable from `honkio.types` (`from honkio.types import Message`)
|
|
39
|
+
- No automatic retries: a send costs money, so retrying is your call (use an idempotency key)
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install honkio
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
Send an SMS:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from honkio import Honkio
|
|
55
|
+
|
|
56
|
+
client = Honkio() # reads HONKIO_API_KEY
|
|
57
|
+
|
|
58
|
+
message = client.messages.send(
|
|
59
|
+
from_="+1416XXXXXXX", # one of your HonkIO numbers
|
|
60
|
+
to="+1613XXXXXXX", # a Canadian number you hold consent for
|
|
61
|
+
body="Hello from HonkIO!",
|
|
62
|
+
idempotency_key="order-1042",
|
|
63
|
+
)
|
|
64
|
+
print(message["id"], message["status"])
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Send an email:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
sent = client.emails.send(
|
|
71
|
+
from_="Acme <hello@yourdomain.ca>", # a verified sending domain
|
|
72
|
+
to="sam@example.com",
|
|
73
|
+
subject="Welcome",
|
|
74
|
+
html="<p>Hello!</p>",
|
|
75
|
+
idempotency_key="welcome-sam",
|
|
76
|
+
)
|
|
77
|
+
print(sent["id"])
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The same calls, asynchronously:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio
|
|
84
|
+
from honkio import AsyncHonkio
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def main() -> None:
|
|
88
|
+
async with AsyncHonkio() as client:
|
|
89
|
+
message = await client.messages.send(from_="+1416XXXXXXX", to="+1613XXXXXXX", body="Hi")
|
|
90
|
+
print(message["id"])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
asyncio.run(main())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`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.
|
|
97
|
+
|
|
98
|
+
## Arguments
|
|
99
|
+
|
|
100
|
+
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")`.
|
|
101
|
+
|
|
102
|
+
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.
|
|
103
|
+
|
|
104
|
+
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).
|
|
105
|
+
|
|
106
|
+
## Errors
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from honkio import ApiError, UnavailableForLegalReasonsError, UnprocessableEntityError, APITimeoutError
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
client.messages.send(from_="+1416XXXXXXX", to="+1613XXXXXXX", body="Hi")
|
|
113
|
+
except UnavailableForLegalReasonsError as e: # HTTP 451
|
|
114
|
+
print(e.code, e.message) # NO_CONSENT, CONSENT_EXPIRED, OPT_OUT_BLOCKED
|
|
115
|
+
except UnprocessableEntityError as e: # HTTP 422
|
|
116
|
+
print(e.code, e.message) # NON_CANADIAN_NUMBER, ...
|
|
117
|
+
except ApiError as e:
|
|
118
|
+
print(e.status_code, e.code, e.details)
|
|
119
|
+
except APITimeoutError:
|
|
120
|
+
... # the send may or may not have gone through: retry with the same idempotency key
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Exception | When |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `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 |
|
|
126
|
+
| `APIConnectionError`, `APITimeoutError` | no response arrived (DNS, refused connection, 30 s timeout) |
|
|
127
|
+
| `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 |
|
|
128
|
+
| `InvalidSignatureError` | a webhook delivery could not be verified |
|
|
129
|
+
| `HonkioError` | the base of all of the above |
|
|
130
|
+
|
|
131
|
+
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`.
|
|
132
|
+
|
|
133
|
+
`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`.
|
|
134
|
+
|
|
135
|
+
The general codes (authentication, permissions, balance, validation, rate limits) and every SMS and email code are listed at <https://honkio.ca/docs/errors>.
|
|
136
|
+
|
|
137
|
+
## Idempotency
|
|
138
|
+
|
|
139
|
+
`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.
|
|
140
|
+
|
|
141
|
+
## Webhooks
|
|
142
|
+
|
|
143
|
+
Check a delivery's signature before you trust it. Pass the raw request body exactly as received, before any JSON parsing:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from honkio import verify_webhook, InvalidSignatureError
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def handle(request): # a Flask-style request
|
|
150
|
+
try:
|
|
151
|
+
event = verify_webhook(request.get_data(), request.headers, WEBHOOK_SECRET)
|
|
152
|
+
except InvalidSignatureError:
|
|
153
|
+
return "bad signature", 400
|
|
154
|
+
if event["type"] == "message.delivered":
|
|
155
|
+
...
|
|
156
|
+
return "", 204
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`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.
|
|
160
|
+
|
|
161
|
+
## Resources
|
|
162
|
+
|
|
163
|
+
| `client.` | Methods |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `messages` | `send`, `get`, `list` |
|
|
166
|
+
| `phone_numbers` | `area_codes`, `search`, `provision`, `list`, `get`, `release` |
|
|
167
|
+
| `verify` | `start`, `check`, `get`, `list` |
|
|
168
|
+
| `consents` | `create`, `list`, `record_batch`, `check`, `revoke` (the last two take exactly one of `phone_number` or `email_address`) |
|
|
169
|
+
| `consent_batches` | `list`, `get` |
|
|
170
|
+
| `opt_in_confirmations` | `send`, `list` |
|
|
171
|
+
| `webhooks` | `create`, `list`, `get`, `update`, `remove`, `deliveries`, `dead_letters`, `replay`, `discard`, `reactivate`, `rotate_secret`, `verify` |
|
|
172
|
+
| `emails` | `send`, `get`, `list`, `update`, `cancel` |
|
|
173
|
+
| `emails.received` | `list`, `address`, `set_address_enabled`, `get`, `raw`, `attachment`, `simulate` |
|
|
174
|
+
| `batch` | `send` (a list of emails, or `recipients=` for one message to many) |
|
|
175
|
+
| `domains` | `create`, `get`, `list`, `update`, `verify`, `remove` |
|
|
176
|
+
| `suppressions` | `create`, `list`, `remove` |
|
|
177
|
+
| `templates` | `create`, `get`, `list`, `update`, `publish`, `rollback`, `versions`, `remove` |
|
|
178
|
+
| `toll_free_verifications` | `options`, `create`, `update`, `submit`, `cancel`, `get`, `list` |
|
|
179
|
+
| `business_verification` | `get`, `save`, `submit` |
|
|
180
|
+
| `auto_topup` | `get` |
|
|
181
|
+
| `topup_cap_requests` | `create`, `list` |
|
|
182
|
+
|
|
183
|
+
`emails.received.raw` and `emails.received.attachment` return `bytes`. A call that answers 204 (the deletes) returns `None`.
|
|
184
|
+
|
|
185
|
+
Full API reference: <https://honkio.ca/docs/api>. SDK guide: <https://honkio.ca/docs/sdk>.
|
|
186
|
+
|
|
187
|
+
## Development
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
uv venv && uv pip install -e '.[dev]'
|
|
191
|
+
ruff check . && mypy --strict src && pytest
|
|
192
|
+
python scripts/generate_types.py # after web/generated/openapi.json changes
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
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.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
honkio-0.1.0/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# honkio
|
|
2
|
+
|
|
3
|
+
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).
|
|
4
|
+
|
|
5
|
+
- Python 3.10 or newer; one dependency, [httpx](https://www.python-httpx.org)
|
|
6
|
+
- A synchronous client (`Honkio`) and an asynchronous one (`AsyncHonkio`) with the same resources and arguments
|
|
7
|
+
- Errors are exceptions; every API error is a subclass of `honkio.ApiError`
|
|
8
|
+
- Typed (`py.typed`); responses are plain dicts described by `TypedDict`s, importable from `honkio.types` (`from honkio.types import Message`)
|
|
9
|
+
- No automatic retries: a send costs money, so retrying is your call (use an idempotency key)
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install honkio
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
Send an SMS:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from honkio import Honkio
|
|
25
|
+
|
|
26
|
+
client = Honkio() # reads HONKIO_API_KEY
|
|
27
|
+
|
|
28
|
+
message = client.messages.send(
|
|
29
|
+
from_="+1416XXXXXXX", # one of your HonkIO numbers
|
|
30
|
+
to="+1613XXXXXXX", # a Canadian number you hold consent for
|
|
31
|
+
body="Hello from HonkIO!",
|
|
32
|
+
idempotency_key="order-1042",
|
|
33
|
+
)
|
|
34
|
+
print(message["id"], message["status"])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Send an email:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
sent = client.emails.send(
|
|
41
|
+
from_="Acme <hello@yourdomain.ca>", # a verified sending domain
|
|
42
|
+
to="sam@example.com",
|
|
43
|
+
subject="Welcome",
|
|
44
|
+
html="<p>Hello!</p>",
|
|
45
|
+
idempotency_key="welcome-sam",
|
|
46
|
+
)
|
|
47
|
+
print(sent["id"])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The same calls, asynchronously:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import asyncio
|
|
54
|
+
from honkio import AsyncHonkio
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def main() -> None:
|
|
58
|
+
async with AsyncHonkio() as client:
|
|
59
|
+
message = await client.messages.send(from_="+1416XXXXXXX", to="+1613XXXXXXX", body="Hi")
|
|
60
|
+
print(message["id"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`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.
|
|
67
|
+
|
|
68
|
+
## Arguments
|
|
69
|
+
|
|
70
|
+
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")`.
|
|
71
|
+
|
|
72
|
+
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.
|
|
73
|
+
|
|
74
|
+
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).
|
|
75
|
+
|
|
76
|
+
## Errors
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from honkio import ApiError, UnavailableForLegalReasonsError, UnprocessableEntityError, APITimeoutError
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
client.messages.send(from_="+1416XXXXXXX", to="+1613XXXXXXX", body="Hi")
|
|
83
|
+
except UnavailableForLegalReasonsError as e: # HTTP 451
|
|
84
|
+
print(e.code, e.message) # NO_CONSENT, CONSENT_EXPIRED, OPT_OUT_BLOCKED
|
|
85
|
+
except UnprocessableEntityError as e: # HTTP 422
|
|
86
|
+
print(e.code, e.message) # NON_CANADIAN_NUMBER, ...
|
|
87
|
+
except ApiError as e:
|
|
88
|
+
print(e.status_code, e.code, e.details)
|
|
89
|
+
except APITimeoutError:
|
|
90
|
+
... # the send may or may not have gone through: retry with the same idempotency key
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Exception | When |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `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 |
|
|
96
|
+
| `APIConnectionError`, `APITimeoutError` | no response arrived (DNS, refused connection, 30 s timeout) |
|
|
97
|
+
| `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 |
|
|
98
|
+
| `InvalidSignatureError` | a webhook delivery could not be verified |
|
|
99
|
+
| `HonkioError` | the base of all of the above |
|
|
100
|
+
|
|
101
|
+
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`.
|
|
102
|
+
|
|
103
|
+
`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`.
|
|
104
|
+
|
|
105
|
+
The general codes (authentication, permissions, balance, validation, rate limits) and every SMS and email code are listed at <https://honkio.ca/docs/errors>.
|
|
106
|
+
|
|
107
|
+
## Idempotency
|
|
108
|
+
|
|
109
|
+
`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.
|
|
110
|
+
|
|
111
|
+
## Webhooks
|
|
112
|
+
|
|
113
|
+
Check a delivery's signature before you trust it. Pass the raw request body exactly as received, before any JSON parsing:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from honkio import verify_webhook, InvalidSignatureError
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def handle(request): # a Flask-style request
|
|
120
|
+
try:
|
|
121
|
+
event = verify_webhook(request.get_data(), request.headers, WEBHOOK_SECRET)
|
|
122
|
+
except InvalidSignatureError:
|
|
123
|
+
return "bad signature", 400
|
|
124
|
+
if event["type"] == "message.delivered":
|
|
125
|
+
...
|
|
126
|
+
return "", 204
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`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.
|
|
130
|
+
|
|
131
|
+
## Resources
|
|
132
|
+
|
|
133
|
+
| `client.` | Methods |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `messages` | `send`, `get`, `list` |
|
|
136
|
+
| `phone_numbers` | `area_codes`, `search`, `provision`, `list`, `get`, `release` |
|
|
137
|
+
| `verify` | `start`, `check`, `get`, `list` |
|
|
138
|
+
| `consents` | `create`, `list`, `record_batch`, `check`, `revoke` (the last two take exactly one of `phone_number` or `email_address`) |
|
|
139
|
+
| `consent_batches` | `list`, `get` |
|
|
140
|
+
| `opt_in_confirmations` | `send`, `list` |
|
|
141
|
+
| `webhooks` | `create`, `list`, `get`, `update`, `remove`, `deliveries`, `dead_letters`, `replay`, `discard`, `reactivate`, `rotate_secret`, `verify` |
|
|
142
|
+
| `emails` | `send`, `get`, `list`, `update`, `cancel` |
|
|
143
|
+
| `emails.received` | `list`, `address`, `set_address_enabled`, `get`, `raw`, `attachment`, `simulate` |
|
|
144
|
+
| `batch` | `send` (a list of emails, or `recipients=` for one message to many) |
|
|
145
|
+
| `domains` | `create`, `get`, `list`, `update`, `verify`, `remove` |
|
|
146
|
+
| `suppressions` | `create`, `list`, `remove` |
|
|
147
|
+
| `templates` | `create`, `get`, `list`, `update`, `publish`, `rollback`, `versions`, `remove` |
|
|
148
|
+
| `toll_free_verifications` | `options`, `create`, `update`, `submit`, `cancel`, `get`, `list` |
|
|
149
|
+
| `business_verification` | `get`, `save`, `submit` |
|
|
150
|
+
| `auto_topup` | `get` |
|
|
151
|
+
| `topup_cap_requests` | `create`, `list` |
|
|
152
|
+
|
|
153
|
+
`emails.received.raw` and `emails.received.attachment` return `bytes`. A call that answers 204 (the deletes) returns `None`.
|
|
154
|
+
|
|
155
|
+
Full API reference: <https://honkio.ca/docs/api>. SDK guide: <https://honkio.ca/docs/sdk>.
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
uv venv && uv pip install -e '.[dev]'
|
|
161
|
+
ruff check . && mypy --strict src && pytest
|
|
162
|
+
python scripts/generate_types.py # after web/generated/openapi.json changes
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
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.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "honkio"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python SDK for HonkIO: Canadian SMS and email, CASL built in."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "somethingcorporate" }]
|
|
14
|
+
keywords = ["sms", "email", "casl", "canada", "honkio", "api"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "mypy>=1.10", "ruff>=0.5"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://honkio.ca/docs/sdk"
|
|
34
|
+
Documentation = "https://honkio.ca/docs/sdk"
|
|
35
|
+
Issues = "https://honkio.ca/contact"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
path = "src/honkio/_version.py"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/honkio"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = ["src", "scripts", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
filterwarnings = ["error"]
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
strict = true
|
|
53
|
+
python_version = "3.10"
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 110
|
|
57
|
+
target-version = "py310"
|
|
58
|
+
extend-exclude = ["src/honkio/_types.py"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "I", "B"]
|
|
62
|
+
# `ruff format` wraps code; the long lines left are docstrings and generated strings.
|
|
63
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Writes src/honkio/_types.py from `components.schemas` in the committed OpenAPI snapshot.
|
|
2
|
+
|
|
3
|
+
python scripts/generate_types.py # rewrite the file
|
|
4
|
+
python scripts/generate_types.py --check # exit 1 if the committed file is stale
|
|
5
|
+
|
|
6
|
+
tests/test_types_drift.py runs `render()` and compares it with the committed file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
18
|
+
SPEC = ROOT.parents[1] / "web" / "generated" / "openapi.json"
|
|
19
|
+
OUT = ROOT / "src" / "honkio" / "_types.py"
|
|
20
|
+
|
|
21
|
+
HEADER = '''"""Response types for the HonkIO API.
|
|
22
|
+
|
|
23
|
+
Generated by scripts/generate_types.py from web/generated/openapi.json. Do not edit.
|
|
24
|
+
Every key is optional (total=False): the API may add fields, and a field a call does not return is absent.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
30
|
+
|
|
31
|
+
'''
|
|
32
|
+
|
|
33
|
+
SCALARS = {"string": "str", "integer": "int", "number": "float", "boolean": "bool"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def class_name(schema_name: str) -> str:
|
|
37
|
+
parts = re.split(r"[^0-9A-Za-z]+", schema_name)
|
|
38
|
+
name = "".join(p[:1].upper() + p[1:] for p in parts if p)
|
|
39
|
+
return name if name and not name[0].isdigit() else f"T{name}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def annotate(schema: Any) -> str:
|
|
43
|
+
if not isinstance(schema, dict):
|
|
44
|
+
return "Any"
|
|
45
|
+
ref = schema.get("$ref")
|
|
46
|
+
if isinstance(ref, str):
|
|
47
|
+
return repr(class_name(ref.rsplit("/", 1)[-1]))
|
|
48
|
+
enum = schema.get("enum")
|
|
49
|
+
if isinstance(enum, list) and enum:
|
|
50
|
+
literals = [repr(v) for v in enum if v is not None]
|
|
51
|
+
inner = f"Literal[{', '.join(literals)}]" if literals else "None"
|
|
52
|
+
return f"Optional[{inner}]" if None in enum and literals else inner
|
|
53
|
+
kind = schema.get("type")
|
|
54
|
+
if isinstance(kind, list):
|
|
55
|
+
kinds = [k for k in kind if k != "null"]
|
|
56
|
+
if len(kinds) == 1:
|
|
57
|
+
inner = annotate({**schema, "type": kinds[0]})
|
|
58
|
+
return f"Optional[{inner}]" if "null" in kind else inner
|
|
59
|
+
return "Any"
|
|
60
|
+
if kind in SCALARS:
|
|
61
|
+
return SCALARS[kind]
|
|
62
|
+
if kind == "array":
|
|
63
|
+
return f"List[{annotate(schema.get('items'))}]"
|
|
64
|
+
if kind == "object":
|
|
65
|
+
return "Dict[str, Any]"
|
|
66
|
+
return "Any"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render(spec: dict[str, Any] | None = None) -> str:
|
|
70
|
+
if spec is None:
|
|
71
|
+
spec = json.loads(SPEC.read_text(encoding="utf-8"))
|
|
72
|
+
schemas: dict[str, Any] = spec["components"]["schemas"]
|
|
73
|
+
entries = sorted((class_name(name), schema) for name, schema in schemas.items())
|
|
74
|
+
lines = [HEADER]
|
|
75
|
+
exported = "".join(f" {name!r},\n" for name, _ in entries)
|
|
76
|
+
lines.append(f"__all__ = [\n{exported}]\n")
|
|
77
|
+
for name, schema in entries:
|
|
78
|
+
properties = schema.get("properties") if isinstance(schema, dict) else None
|
|
79
|
+
fields = properties if isinstance(properties, dict) else {}
|
|
80
|
+
body = "".join(f" {key!r}: {annotate(value)},\n" for key, value in fields.items())
|
|
81
|
+
mapping = f"{{\n{body} }}" if body else "{}"
|
|
82
|
+
lines.append(f"\n{name} = TypedDict(\n {name!r},\n {mapping},\n total=False,\n)\n")
|
|
83
|
+
return "".join(lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> int:
|
|
87
|
+
text = render()
|
|
88
|
+
if "--check" in sys.argv:
|
|
89
|
+
if OUT.read_text(encoding="utf-8") != text:
|
|
90
|
+
print(f"{OUT} is stale; run python scripts/generate_types.py", file=sys.stderr)
|
|
91
|
+
return 1
|
|
92
|
+
return 0
|
|
93
|
+
OUT.write_text(text, encoding="utf-8")
|
|
94
|
+
print(f"wrote {OUT}")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Official Python SDK for HonkIO: Canadian SMS and email, CASL built in."""
|
|
2
|
+
|
|
3
|
+
from ._client import AsyncHonkio, Honkio
|
|
4
|
+
from ._core import NOT_GIVEN, NotGiven
|
|
5
|
+
from ._endpoints import ENDPOINTS, Endpoint
|
|
6
|
+
from ._errors import (
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
ApiError,
|
|
9
|
+
APITimeoutError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
BadRequestError,
|
|
12
|
+
ConflictError,
|
|
13
|
+
HonkioError,
|
|
14
|
+
InternalServerError,
|
|
15
|
+
InvalidArgumentError,
|
|
16
|
+
InvalidSignatureError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
PaymentRequiredError,
|
|
19
|
+
PermissionDeniedError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
UnavailableForLegalReasonsError,
|
|
22
|
+
UnprocessableEntityError,
|
|
23
|
+
)
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
from ._webhooks import WEBHOOK_EVENTS, verify_webhook
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ENDPOINTS",
|
|
29
|
+
"NOT_GIVEN",
|
|
30
|
+
"WEBHOOK_EVENTS",
|
|
31
|
+
"APIConnectionError",
|
|
32
|
+
"APITimeoutError",
|
|
33
|
+
"ApiError",
|
|
34
|
+
"AsyncHonkio",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"BadRequestError",
|
|
37
|
+
"ConflictError",
|
|
38
|
+
"Endpoint",
|
|
39
|
+
"Honkio",
|
|
40
|
+
"HonkioError",
|
|
41
|
+
"InternalServerError",
|
|
42
|
+
"InvalidArgumentError",
|
|
43
|
+
"InvalidSignatureError",
|
|
44
|
+
"NotGiven",
|
|
45
|
+
"NotFoundError",
|
|
46
|
+
"PaymentRequiredError",
|
|
47
|
+
"PermissionDeniedError",
|
|
48
|
+
"RateLimitError",
|
|
49
|
+
"UnavailableForLegalReasonsError",
|
|
50
|
+
"UnprocessableEntityError",
|
|
51
|
+
"__version__",
|
|
52
|
+
"verify_webhook",
|
|
53
|
+
]
|