TypeScript SDK
@hookchat/node is the official Node and TypeScript SDK. The send policy lives in the types: messages.reply and messages.sendAsHumanAgent are the only send paths, actor_id is required on the second, and there is no generic send and no tags anywhere, so misuse does not compile.
Install#
npm install @hookchat/nodeRequires Node 20 or newer (it uses the global fetch and AbortController). ESM only. The package is on the downloads page along with its changelog.
Create a client#
apiKey and baseUrl are required; everything else has a default. Every method returns a promise of the unwrapped data of the API envelope and rejects with a HookChatError on any failure. Field names are snake_case, exactly as the API returns them.
import { HookChat } from '@hookchat/node'
const client = new HookChat({
apiKey: process.env.HOOKCHAT_API_KEY!, // hookchat_live_... or hookchat_test_...
baseUrl: process.env.HOOKCHAT_BASE_URL!, // the API origin, https://hookchat.dev by default
})
const { name, version, time } = await client.ping() // GET /v1/ping, unauthenticated
console.log('ok', name, version, time)| Option | Default | Meaning |
|---|---|---|
apiKey | required | Sent as Authorization: Bearer .... |
baseUrl | required | The API origin. Trailing slash optional. |
tenant | none | A default ?tenant for every call that does not name one. Needed for an operator key; harmless on a bound key. |
timeoutMs | 30000 | Per-request deadline, connect to last body byte. |
maxRetries | 2 | Retries per request (so three attempts). 0 disables retries. |
retryBaseDelayMs | 250 | First backoff delay; doubles each retry. |
retryMaxDelayMs | 8000 | Backoff ceiling. |
retryAfterMaxMs | 60000 | The longest Retry-After honoured. |
fetch | global | Override fetch for a proxy, a custom agent or tests. |
Every method also takes an optional trailing RequestOptions argument: { signal?, timeoutMs?, maxRetries? }.
The two, and only two, send paths#
Both accept attachments (a media send; text becomes optional once there is at least one attachment) and reply_to (a platform message id to thread under). Omitting actor_id on sendAsHumanAgent, or passing it to reply, is a compile error.
import { HookChat } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
const conversation_id = process.env.CONVERSATION_ID!
// Inside the 24 hour window.
const reply = await client.messages.reply({ conversation_id, text: 'Thanks, on it.' })
console.log('reply sent', reply.platform_message_id)
// Between 24 hours and 7 days, typed by a human. actor_id is required by the type.
const followUp = await client.messages.sendAsHumanAgent({ conversation_id, text: 'Following up.', actor_id: 'agent-7' })
console.log('human-agent sent', followUp.platform_message_id)Resources and methods#
Every list method returns one page ({ <items>, next_cursor }) and every paginated resource has an iterate method. tenant may be passed on any read or admin call.
| Resource | Methods | Routes |
|---|---|---|
client.ping() | GET /v1/ping | |
client.conversations | list, iterate, get(id, { include_test }) | GET /v1/conversations, GET /v1/conversations/:id |
client.messages | reply, sendAsHumanAgent, list, iterate, get | POST /v1/messages/reply, POST /v1/messages/human-agent, GET /v1/messages, GET /v1/messages/:id |
client.accounts | list | GET /v1/accounts |
client.audit | list, iterate | GET /v1/audit |
client.stats | get | GET /v1/stats |
client.keys | create, list, revoke | POST /v1/keys, GET /v1/keys, DELETE /v1/keys/:id |
client.webhooks | create, list, get, update, delete, rotate, test | /v1/webhooks, /v1/webhooks/:id, .../rotate, .../test |
client.webhooks.deliveries | list, iterate, summary, replay, replayAll | /v1/webhooks/:id/deliveries, .../summary, .../:deliveryId/replay, .../replay |
client.realtime | ticket | POST /v1/realtime/ticket |
import { HookChat } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
const accounts = await client.accounts.list() // not paginated
for (const a of accounts) if (a.refresh_error) console.warn(a.handle, 'needs relinking:', a.refresh_error)
const stats = await client.stats.get()
console.log('messages_24h', stats.messages_24h, 'dlq_total', stats.dlq_total, 'endpoints', stats.endpoints.length)
const audit = await client.audit.list({ limit: 5 }) // most recent first
for (const entry of audit.audit) console.log(entry.at, entry.actor, entry.action, entry.resource)
const endpoints = await client.webhooks.list() // never returns a secret
console.log(endpoints.length, 'endpoints,', accounts.length, 'accounts')Errors#
Every 4xx or 5xx envelope is raised as a HookChatError carrying the stable code, the HTTP status, the optional detail, and requestId (the x-correlation-id header). The same class covers transport failures with codes timeout, network_error and aborted, and non-envelope responses with server_error and invalid_response. Branch on err.code, never on the message.
import { HookChat, HookChatError } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
try {
await client.messages.reply({ conversation_id: process.env.CLOSED_CONVERSATION_ID!, text: 'Hello again' })
} catch (error) {
if (!(error instanceof HookChatError)) throw error
switch (error.code) {
case 'window_closed':
console.log('refused:', error.code, 'status', error.status, 'request', error.requestId)
break
case 'rate_limited':
// Back off; the SDK never retries a send.
break
case 'send_failed':
// Meta refused; error.detail carries the reason and a retry may help.
break
default:
throw error
}
}Verifying deliveries#
verifyWebhook(rawBody, headers, secret, options?) checks HookChat-Signature against the raw bytes with a timing-safe compare, enforces a 300 second timestamp tolerance, accepts any of several v1= digests during a rotation overlap, and returns the parsed WebhookEvent union. It throws HookChatSignatureError on any failure and never returns unverified data. headers can be a plain headers map, a Headers instance or the raw header string.
import { createServer } from 'node:http'
import { verifyWebhook, HookChatSignatureError } from '@hookchat/node'
const secret = process.env.WEBHOOK_SECRET!
createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => {
try {
const event = verifyWebhook(Buffer.concat(chunks), req.headers, secret)
if (event.type === 'message.received') {
console.log('inbound', event.data.message.conversation_id, event.data.message.text)
} else {
console.log('verified', event.type, event.id)
}
res.writeHead(200).end()
} catch (error) {
if (error instanceof HookChatSignatureError) {
res.writeHead(401).end()
return
}
throw error
}
})
}).listen(Number(process.env.PORT))The union narrows on type: MessageEvent (message.received, message.sent), MessageFailedEvent, TestEvent and AccountEvent. Deliveries also carry HookChat-Timestamp, HookChat-Event-Id, HookChat-Event-Type and HookChat-Delivery-Id; dedupe on event.id.
Pagination#
List methods take limit (1 to 100, default 50) and cursor and return next_cursor (null on the last page). The iterate helpers follow the cursor lazily and maxItems caps the total. The generic paginate(fetchPage, { maxItems }) is exported for anything else shaped { items, next_cursor }.
import { HookChat } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
let unanswered = 0
for await (const c of client.conversations.iterate({ limit: 100, maxItems: 500 })) {
if (c.unanswered && c.can_reply) unanswered += 1
}
console.log(unanswered, 'unanswered conversations still inside the window')
for await (const entry of client.audit.iterate({ maxItems: 3 })) console.log(entry.action, entry.resource)Retries and timeouts#
Reads are retried on 429, 5xx, a timeout and a network error; webhooks.test and the replays on 429 only; every other write, sends included, never. The backoff is equal-jitter exponential from retryBaseDelayMs to retryMaxDelayMs, replaced by Retry-After when the response carries one. Pass an AbortSignal to cancel; that raises code aborted and is never retried.
import { HookChat } from '@hookchat/node'
const client = new HookChat({
apiKey: process.env.HOOKCHAT_API_KEY!,
baseUrl: process.env.HOOKCHAT_BASE_URL!,
timeoutMs: 10_000,
maxRetries: 3,
})
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 2_000)
const stats = await client.stats.get({}, { signal: controller.signal, timeoutMs: 5_000, maxRetries: 0 })
clearTimeout(timer)
console.log('window_hours', stats.window_hours)Versioning#
The package follows semantic versioning. VERSION is exported and woven into the User-Agent: HookChat/<version> (Jiffi) header. Within a major version, method names, parameter shapes and error codes only grow; a renamed method keeps its old name as a deprecated alias (webhooks.deliveries.bulkReplay is one). The closed ErrorCode union mirrors the API vocabulary one to one; a new code is a minor bump.