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) - Retries what waiting fixes (a number's per-minute sending rate, the carrier asking to slow down), with an idempotency key so a retried send never goes out twice
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, max_retries=2, max_retry_delay=60.0); see Retries. 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:
... # retried already; the send may or may not have gone through: see Retries
| 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.
Retries
The client sends a call again when the API refused it only because it should wait:
| Code | Retried |
|---|---|
NUMBER_RATE_LIMITED (429) |
always: the sending number already has a minute of message parts waiting at the carrier (a local number sends 10 parts a minute) |
CARRIER_THROTTLED (503) |
always: the carrier asked HonkIO to slow down and did not take the message; nothing was charged |
CARRIER_UNAVAILABLE (503) |
always: the carrier could not be reached; nothing was sent or charged |
RATE_LIMITED (429) |
on a GET or a send with an idempotency key. Elsewhere it can be the route's own limit, such as one verification start per recipient a minute, where a repeat would send again |
APIConnectionError, APITimeoutError |
on a GET or a send with an idempotency key: the request may have reached the API |
Each retry waits as long as the API's Retry-After header (seconds, or an HTTP date to wait until) or details["retry_after_seconds"] says, or backs off exponentially with jitter when it says nothing. By default a call is retried at most 2 times, and a wait longer than 60 seconds is not waited out: the exception is raised at once. When retrying does not help, the last exception is raised, the same as without retries. AsyncHonkio waits with asyncio.sleep, so other tasks keep running.
client = Honkio(max_retries=4, max_retry_delay=30)
no_retries = Honkio(max_retries=0)
Never retried: CARRIER_TIMEOUT (the message may have gone out: check its status with messages.get before sending again), DAILY_LIMIT_REACHED, FANOUT_LIMIT_REACHED, RECIPIENT_RATE_LIMITED, SENDING_PAUSED, NUMBER_QUEUE_FULL, and every validation, consent or authentication error.
A local number sends 10 message parts a minute, so a burst bigger than that is spread out at that pace: each refusal says how long until there is room (about 6 seconds per part waiting). For a list, send to a contact group or pace your sends; for real volume, use a verified toll-free number. Do not spread one list across several numbers to go faster: carriers treat that as snowshoeing and it breaks the acceptable use policy.
Send limit and number queues
messages.send returns a SentMessage: the message plus estimated_send_at, when the carrier should start sending it, from the parts already waiting on the sending number (None in test mode and on a retry that returned an earlier send). send_limit.get() shows the rest before a burst: the daily cap and what is left of it, the per-recipient caps, probation, any pause, past volume requests, and number_queues, each number's carrier queue.
limits = client.send_limit.get()
print(limits["remaining"], "of", limits["daily_limit"], "left today")
for queue in limits["number_queues"]:
# parts_per_minute is 10 for a local number; clears_at is None when nothing is waiting.
print(queue["phone_number"], queue["queued_parts"], queue["parts_per_minute"], queue["clears_at"])
Idempotency
messages.send, emails.send and batch.send always send an Idempotency-Key header: yours when you pass idempotency_key=, otherwise a generated UUID that stays the same on each retry of that call. The API answers a repeated key with the original result, so a retry can never send or charge twice.
The generated key covers only the client's own retries. If your code sends again after an exception (a task queue running a job again, say), pass your own key, derived from what is being sent (an order id), so the API recognises the repeat.
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 (returns a SentMessage, with estimated_send_at), 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 |
send_limit |
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.2.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.2.0.tar.gz | 32.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| honkio-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 77.3 kB
Release files / honkio-0.2.0.tar.gz
| Download URL | honkio-0.2.0.tar.gz |
|---|---|
| Size | 32.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d91fe0a9ed0aab729558635faf1cfde6eb68c99101a99e24d0db2086fb0e0b89
|
|
BLAKE2b-256 checksum How to use checksums |
e3fecc510eef6f92b1c8eadbfed337fc103d603209e4ddde7190a3eb58b8bfe8
|
| 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.2.0-py3-none-any.whl
| Download URL | honkio-0.2.0-py3-none-any.whl |
|---|---|
| Size | 44.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ae21398ec168ee7dfbf6b3ad11139b9c7574025b25d08ee24f93ec643d533dfd
|
|
BLAKE2b-256 checksum How to use checksums |
aa16264d281e1477fc16a9dd3259eab04b0be21af57a91ddbb4ccc57200fee5f
|
| 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}
|