honkio
The official Python SDK for HonkIO: Canadian SMS and email, with CASL built in. Data is stored in Canada (ca-central-1).
- Python 3.10 or newer; one dependency, httpx
- 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 byTypedDicts, importable fromhonkio.types(from honkio.types import Message) - No automatic retries: a send costs money, so retrying is your call (use an idempotency key)
Install
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:
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:
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:
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
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:
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
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
Metadata
Release files for honkio 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| honkio-0.1.0.tar.gz | 28.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| honkio-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 68.0 kB
Release files / honkio-0.1.0.tar.gz
| Download URL | honkio-0.1.0.tar.gz |
|---|---|
| Size | 28.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
32d12ef8546fd47b4f9fa2ff51d1bd91800274506069985e641472a85a5314f6
|
|
BLAKE2b-256 checksum How to use checksums |
23fba570f6752cd634b4834d7615864b0069b4c0edbca6a15ebe00854be3ec80
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / honkio-0.1.0-py3-none-any.whl
| Download URL | honkio-0.1.0-py3-none-any.whl |
|---|---|
| Size | 39.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
19fc784517d50b085ad08eb1ebd4f20c07c9228c3e667fffbaae74a73d52ae24
|
|
BLAKE2b-256 checksum How to use checksums |
2aaf7605300296acfdf2c1a46bb2a0b908052e135c3bd7c0db7a1b2cba1fc40e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|