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#

Shell
npm install @hookchat/node

Requires 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.

TypeScript
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)
OptionDefaultMeaning
apiKeyrequiredSent as Authorization: Bearer ....
baseUrlrequiredThe API origin. Trailing slash optional.
tenantnoneA default ?tenant for every call that does not name one. Needed for an operator key; harmless on a bound key.
timeoutMs30000Per-request deadline, connect to last body byte.
maxRetries2Retries per request (so three attempts). 0 disables retries.
retryBaseDelayMs250First backoff delay; doubles each retry.
retryMaxDelayMs8000Backoff ceiling.
retryAfterMaxMs60000The longest Retry-After honoured.
fetchglobalOverride 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.

TypeScript
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.

ResourceMethodsRoutes
client.ping()GET /v1/ping
client.conversationslist, iterate, get(id, { include_test })GET /v1/conversations, GET /v1/conversations/:id
client.messagesreply, sendAsHumanAgent, 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, test/v1/webhooks, /v1/webhooks/:id, .../rotate, .../test
client.webhooks.deliverieslist, iterate, summary, replay, replayAll/v1/webhooks/:id/deliveries, .../summary, .../:deliveryId/replay, .../replay
client.realtimeticketPOST /v1/realtime/ticket
A tour of the reads
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.

TypeScript
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.

TypeScript
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 }.

TypeScript
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.

TypeScript
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.

Next#