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)
  • 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)

Source distribution for honkio 0.2.0
File Size Uploaded
honkio-0.2.0.tar.gz 32.6 kB Details

Built distribution (wheel)

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

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

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