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.
Files changed (36) hide show
  1. honkio-0.1.0/.gitignore +7 -0
  2. honkio-0.1.0/CHANGELOG.md +16 -0
  3. honkio-0.1.0/LICENSE +21 -0
  4. honkio-0.1.0/PKG-INFO +199 -0
  5. honkio-0.1.0/README.md +169 -0
  6. honkio-0.1.0/pyproject.toml +63 -0
  7. honkio-0.1.0/scripts/generate_types.py +99 -0
  8. honkio-0.1.0/src/honkio/__init__.py +53 -0
  9. honkio-0.1.0/src/honkio/_client.py +139 -0
  10. honkio-0.1.0/src/honkio/_core.py +280 -0
  11. honkio-0.1.0/src/honkio/_endpoints.py +93 -0
  12. honkio-0.1.0/src/honkio/_errors.py +101 -0
  13. honkio-0.1.0/src/honkio/_transport.py +114 -0
  14. honkio-0.1.0/src/honkio/_types.py +561 -0
  15. honkio-0.1.0/src/honkio/_version.py +1 -0
  16. honkio-0.1.0/src/honkio/_webhooks.py +138 -0
  17. honkio-0.1.0/src/honkio/py.typed +0 -0
  18. honkio-0.1.0/src/honkio/resources/__init__.py +53 -0
  19. honkio-0.1.0/src/honkio/resources/auto_topup.py +35 -0
  20. honkio-0.1.0/src/honkio/resources/batch.py +109 -0
  21. honkio-0.1.0/src/honkio/resources/business_verification.py +117 -0
  22. honkio-0.1.0/src/honkio/resources/consent_batches.py +59 -0
  23. honkio-0.1.0/src/honkio/resources/consents.py +217 -0
  24. honkio-0.1.0/src/honkio/resources/domains.py +158 -0
  25. honkio-0.1.0/src/honkio/resources/emails.py +260 -0
  26. honkio-0.1.0/src/honkio/resources/messages.py +166 -0
  27. honkio-0.1.0/src/honkio/resources/opt_in_confirmations.py +87 -0
  28. honkio-0.1.0/src/honkio/resources/phone_numbers.py +133 -0
  29. honkio-0.1.0/src/honkio/resources/received_emails.py +252 -0
  30. honkio-0.1.0/src/honkio/resources/suppressions.py +85 -0
  31. honkio-0.1.0/src/honkio/resources/templates.py +280 -0
  32. honkio-0.1.0/src/honkio/resources/toll_free_verifications.py +173 -0
  33. honkio-0.1.0/src/honkio/resources/topup_cap_requests.py +81 -0
  34. honkio-0.1.0/src/honkio/resources/verify.py +142 -0
  35. honkio-0.1.0/src/honkio/resources/webhooks.py +271 -0
  36. honkio-0.1.0/src/honkio/types.py +12 -0
@@ -0,0 +1,7 @@
1
+ .venv*/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .mypy_cache/
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -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
+ ]