Skip to main content

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 by TypedDicts, importable from honkio.types (from honkio.types import Message)
  • No automatic retries: a send costs money, so retrying is your call (use an idempotency key)

Install

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)

Source distribution for honkio 0.1.0
File Size Uploaded
honkio-0.1.0.tar.gz 28.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for honkio 0.1.0
File Interpreter ABI Platform
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}

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page