Python SDK

hookchat is the official Python SDK. One dependency (httpx), fully typed, a sync client and an async client with the same surface, automatic retries on reads, and verify_webhook for the deliveries the gateway sends you.

Install#

Shell
pip install hookchat

Python 3.10 or newer. uv add hookchat works too. The package is on the downloads page along with its changelog.

Create a client#

The client reads nothing from the environment; pass the key explicitly. Use it as a context manager (with HookChat(...) as client:) or call client.close() to release the connection pool.

Python
import os

from hookchat import HookChat

client = HookChat(
    api_key=os.environ["HOOKCHAT_API_KEY"],  # hookchat_live_... or hookchat_test_...
    base_url=os.environ["HOOKCHAT_BASE_URL"],  # the API origin, https://hookchat.dev by default
    timeout=30.0,  # seconds, or an httpx.Timeout
    max_retries=2,  # retry budget for retryable failures
    tenant=None,  # default ?tenant for operator keys
)

ping = client.ping()  # unauthenticated liveness probe
print("ok", ping.name, ping.version, ping.time)
ArgumentDefaultMeaning
api_keyrequiredSent as a bearer token on every call.
base_urlhttps://hookchat.devThe API origin.
timeout30.0Seconds (10 to connect), or an httpx.Timeout.
max_retries2Retry budget for retryable failures. 0 disables retries.
tenantNoneDefault ?tenant for operator keys. Bound keys need none.
default_headersNoneExtra headers on every request.
http_clientNoneReuse your own httpx.Client (proxies, custom transports). The SDK will not close it.

The send policy#

Two send paths and no generic send. reply works inside 24 hours of the last inbound message; send_as_human_agent works from 24 hours to 7 days and takes actor_id as a required keyword. Outside the window reply raises WindowClosedError; past 7 days send_as_human_agent raises HumanAgentUnavailableError. text may be omitted when attachments is given, and reply_to threads under a platform message id.

Python
import os

from hookchat import HookChat

client = HookChat(api_key=os.environ["HOOKCHAT_API_KEY"], base_url=os.environ["HOOKCHAT_BASE_URL"])
conversation_id = os.environ["CONVERSATION_ID"]

# Inside the 24 hour window.
reply = client.messages.reply(conversation_id, "Thanks, on it.")
print("reply sent", reply.platform_message_id)

# Between 24 hours and 7 days, typed by a human. actor_id is required.
follow_up = client.messages.send_as_human_agent(conversation_id, "Following up.", actor_id="agent-7")
print("human-agent sent", follow_up.platform_message_id)

Resources#

Every method takes an optional tenant= keyword for operator keys. Conversation ids contain #; the SDK percent-encodes them for you. Reads exclude test-scope rows unless you pass include_test=True.

ResourceMethodsRoutes
client.ping()GET /v1/ping
client.conversationslist, iterate, getGET /v1/conversations, GET /v1/conversations/{id}
client.messagesreply, send_as_human_agent, list, iterate, getPOST /v1/messages/reply, POST /v1/messages/human-agent, GET /v1/messages, GET /v1/messages/{id}
client.accountslistGET /v1/accounts
client.auditlist, iterateGET /v1/audit
client.statsgetGET /v1/stats
client.keyscreate, list, revokePOST /v1/keys, GET /v1/keys, DELETE /v1/keys/{id}
client.webhookscreate, list, get, update, delete, rotate_secret, test/v1/webhooks, /v1/webhooks/{id}, .../rotate, .../test
client.webhooks.deliverieslist, iterate, summary, replay, replay_all/v1/webhooks/{id}/deliveries, .../summary, .../{delivery_id}/replay, .../replay
client.realtimeticketPOST /v1/realtime/ticket
client.request(method, path)Escape hatch for any /v1 path; returns the envelope's data unparsed.
A tour of the reads
import os

from hookchat import HookChat

client = HookChat(api_key=os.environ["HOOKCHAT_API_KEY"], base_url=os.environ["HOOKCHAT_BASE_URL"])

accounts = client.accounts.list()  # not paginated
for account in accounts:
    if account.refresh_error:
        print(account.handle, "needs relinking:", account.refresh_error)

stats = client.stats.get()
print("messages_24h", stats.messages_24h, "dlq_total", stats.dlq_total, "endpoints", len(stats.endpoints))

for entry in client.audit.list(limit=5):  # a Page iterates over its items
    print(entry.at, entry.actor, entry.action, entry.resource)

endpoints = client.webhooks.list()  # never returns a secret
print(len(endpoints), "endpoints,", len(accounts), "accounts")

Errors#

Every API failure raises HookChatError or a subclass carrying code, status, message, detail, request_id and body. Catch the family class or branch on code; never parse the message.

ExceptionWhen
AuthenticationError401, the key is missing or invalid
PermissionDeniedError403, the key is not scoped to that tenant
ValidationError400, malformed request or tenant missing on an operator key
NotFoundError404, no such conversation, message, endpoint, key or delivery
ConflictError409, the resource's state refused the request
WindowClosedError409 window_closed, subclass of ConflictError
HumanAgentUnavailableError409 human_agent_unavailable
MissingActorError409 missing_actor
ReplayConflictError409 replay_conflict
RateLimitError409 rate_limited (send budget) or an HTTP 429
SendFailedError502 send_failed, Meta or the network refused; detail has the reason
ServerErrorany other 5xx
APIConnectionErrorthe gateway could not be reached after retries
APITimeoutErrorthe request timed out after retries
Python
import os

from hookchat import HookChat, HookChatError, NotFoundError, WindowClosedError

client = HookChat(api_key=os.environ["HOOKCHAT_API_KEY"], base_url=os.environ["HOOKCHAT_BASE_URL"])

try:
    client.messages.reply(os.environ["CLOSED_CONVERSATION_ID"], "Hello again")
except WindowClosedError as error:
    print("refused:", error.code, "status", error.status, "request", error.request_id)
except NotFoundError as error:
    print("no such conversation:", error.code)
except HookChatError as error:
    print(error.code, error.status, error.message, error.detail)

Webhook verification#

verify_webhook(payload, headers, secret, tolerance=300) checks the HookChat-Signature digest over the raw bytes, rejects timestamps more than 300 seconds from now, accepts either digest during a rotation overlap, and returns the typed event. It raises SignatureVerificationError, which is deliberately not a HookChatError because it carries no HTTP status. Always pass the raw request bytes. compute_signature(payload, timestamp, secret) is exported for building fixtures.

Python
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

from hookchat import MessageEvent, SignatureVerificationError, verify_webhook

SECRET = os.environ["WEBHOOK_SECRET"]


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))  # the exact bytes
        try:
            event = verify_webhook(raw, dict(self.headers.items()), SECRET)
        except SignatureVerificationError:
            self.send_response(401)
            self.end_headers()
            return
        if isinstance(event, MessageEvent) and event.type == "message.received":
            print("inbound", event.message.conversation_id, event.message.text, flush=True)
        else:
            print("verified", event.type, event.id, flush=True)
        self.send_response(200)
        self.end_headers()


HTTPServer(("", int(os.environ["PORT"])), Receiver).serve_forever()

Events are MessageEvent (message.received, message.sent), MessageFailedEvent, TestEvent and AccountEvent. A type this SDK does not know yet parses as a plain Event with its raw data, so a newer gateway never breaks an older consumer. Deliveries are at least once and unordered; dedupe on event.id. In Flask use request.get_data(), in FastAPI await request.body().

Pagination#

List methods return a Page with items, cursor and has_more. Pass the cursor back, or use iterate, which follows it and accepts max_items. iterate exists on conversations, messages, audit and webhooks.deliveries. Limits are clamped to 100 by the gateway.

Python
import os

from hookchat import HookChat

client = HookChat(api_key=os.environ["HOOKCHAT_API_KEY"], base_url=os.environ["HOOKCHAT_BASE_URL"])

unanswered = 0
for conversation in client.conversations.iterate(limit=100, max_items=500):
    if conversation.unanswered and conversation.can_reply:
        unanswered += 1
print(unanswered, "unanswered conversations still inside the window")

page = client.audit.list(limit=3)
print(len(page), "audit entries, has_more:", page.has_more)

Retries and timeouts#

Reads (every GET) are retried on HTTP 429, 5xx, connection errors and timeouts with jittered exponential backoff (0.5 s base, 8 s cap), honouring Retry-After when present (capped at 60 s). webhooks.test, deliveries.replay and deliveries.replay_all are retried on 429 only. Every other mutation, including both send methods, is never retried. with_options changes the timeout, the retry budget or the tenant per call site without a new connection pool.

Python
import os

from hookchat import HookChat

client = HookChat(api_key=os.environ["HOOKCHAT_API_KEY"], base_url=os.environ["HOOKCHAT_BASE_URL"], max_retries=3)

fast = client.with_options(timeout=5, max_retries=0)
stats = fast.stats.get()
print("window_hours", stats.window_hours)

Async usage#

AsyncHookChat has the same surface. Every method is a coroutine and iterate returns an async iterator.

Python
import asyncio
import os

from hookchat import AsyncHookChat


async def main():
    async with AsyncHookChat(
        api_key=os.environ["HOOKCHAT_API_KEY"], base_url=os.environ["HOOKCHAT_BASE_URL"]
    ) as client:
        stats, accounts = await asyncio.gather(client.stats.get(), client.accounts.list())
        print("messages_24h", stats.messages_24h, "accounts", len(accounts))
        async for conversation in client.conversations.iterate(max_items=5):
            print(conversation.id, conversation.window.state)
        print("async done")


asyncio.run(main())

Versioning#

The SDK follows semantic versioning. Minor releases add fields, methods and event types; the models ignore unknown fields, so a newer gateway never breaks an older SDK. Breaking changes to the public API bump the major version. hookchat.__version__ reports the installed version.

Next#