API Docs

@livechat/sdk-node

AdminUpdated Sep 19, 2026

@livechat/sdk-node

The official server SDK for Node.js — typed REST client, per-write idempotency, webhook signature verification.

@livechat/sdk-node is the official server-side SDK for Chatly. It wraps the key-accessible slice of the REST API with typed methods, automatic idempotency keys on every write, webhook signature verification, and the Identity Verification HMAC helper.

It authenticates with a workspace API key, so it belongs on your backend — not in a browser, where the key would be readable. For the browser, use @livechat/sdk-js.

Info — ESM, types included, zero dependencies

Ships ESM with native TypeScript types and an empty dependency list — it uses the runtime's global fetch. Requires Node 18+, or any runtime that provides both fetch and the node:crypto builtin (Deno and Bun do; Vercel Edge does not).

Install

pnpm

pnpm add @livechat/sdk-node

npm

npm install @livechat/sdk-node

yarn

yarn add @livechat/sdk-node

bun

bun add @livechat/sdk-node

Quick start

import { ChatlyClient } from '@livechat/sdk-node';

const chatly = new ChatlyClient({
  apiKey: process.env.CHATLY_API_KEY!, // ck_live_…
  apiUrl: 'https://api.chatlychat.com',
});

// Make this your first call — see below.
const { workspaceId, scopes } = await chatly.whoami();

await chatly.contacts.upsert({
  externalId: 'user_42',
  email: '[email protected]',
  name: 'Jamie',
  attributes: { plan: 'business', mrr: 199 },
});

await chatly.conversations.send({
  conversationId: 'conv_01H7...',
  body: 'Hello from the SDK',
  kind: 'text',
});

Mint a key under Integrations → Developer → API keys. The token is shown once and stored only as a hash — if you lose it, revoke it and mint another. A production key reads ck_live_…; anywhere else it reads ck_test_…, so a key pasted into the wrong deployment fails recognisably. The SDK sends it as Authorization: Bearer <key>.

Warning — Call whoami() first

GET /v1/api-keys/whoami — Bearer token returns the workspace the key belongs to and the scopes it actually holds, so a mis-scoped key fails loudly at integration time instead of surfacing as a puzzling 403 from an unrelated endpoint in production. It is also the only api-keys route a key may call — minting or listing keys with a key would let a leaked read-only key escalate itself.

Configuration

ClientOptions

Name

Type

Description

apiKey (required)

string

A workspace API key (ck_live_…). Mint at Integrations → Developer → API keys.

apiUrl

string

Your Chatly host. Set this — the default is an internal development host and is wrong for every real deployment. Self-hosting needs no other change. Default: https://api.livechat.local.

timeoutMs

integer

Per-request timeout. AbortController-backed. Default: 15000.

fetchFn

typeof fetch

Override the global fetch. Useful for proxying or testing.

There is no user-token, refresh-token, retry, user-agent or telemetry-hook configuration — these four options are the whole surface. Instrument by passing a wrapped fetchFn.

What the client can do

Key authentication is opt-in per route. Only a small, deliberately reviewed set of endpoints accepts a ck_ key; every other route answers one with 403 This endpoint requires a signed-in user; API keys cannot be used here. Every method below maps to an endpoint that does accept a key.

Methods and the scope each needs

Name

Type

Description

chatly.whoami()

workspace:read

GET /v1/api-keys/whoami

chatly.contacts.list({ cursor?, limit? })

contacts:read

GET /v1/contacts

chatly.contacts.upsert(contact)

contacts:write

POST /v1/contacts — matches on externalId, never on email.

chatly.conversations.list({ cursor?, limit? })

conversations:read

GET /v1/conversations

chatly.conversations.messages(id, { cursor?, limit? })

messages:read

GET /v1/conversations/:id/messages

chatly.conversations.send({ conversationId, body, kind? })

messages:write

POST /v1/conversations/:id/messageskind is text, rich, note or system_event.

chatly.search.query(q, opts?)

search:read

GET /v1/search — contacts, conversations, messages, tickets and KB.

chatly.search.kb(q, opts?)

search:read

The same route narrowed to kinds[]=kb — help-centre articles only.

chatly.events.track({ contactId, name, properties? })

contacts:write

POST /v1/cdp/events

The full scope vocabulary a key can be granted is contacts:read, contacts:write, conversations:read, messages:read, messages:write, kb:read, search:read, workspace:read. Scope a key to only what your integration needs.

Warning — Conversations are started by the visitor, not by a key

There is no create-conversation route in the API, and no conversations.create() in the SDK. A conversation comes into existence when a contact opens one on a channel — through the widget, an inbound email, or another connected channel. Server-side code replies into a conversation that already exists; get its ID from conversations.list() or from a webhook.

Reactions and channel/widget configuration are absent on purpose. A reaction records who reacted and a key is not a person, so those routes require a signed-in user. Channel configuration needs a scope no key can hold. Both used to be here and both returned 403 to every caller.

Attribution

Messages you send with a key are recorded as sent by an integration, not by the person who created the key — a key outlives whoever issued it, so attributing its writes to them would be wrong the moment they leave.

Pagination

List methods take { cursor?, limit? } and return a Page<T>:

let cursor: string | null = null;
do {
  const page = await chatly.conversations.list({ limit: 100, ...(cursor ? { cursor } : {}) });
  for (const conv of page.items) {
    console.log(conv);
  }
  cursor = page.nextCursor;
} while (cursor !== null);

Page<T> is { items: readonly T[]; nextCursor: string | null }. There is no auto-paginating iterator helper — walk nextCursor as above.

Idempotency

The API requires an Idempotency-Key on every write, and the SDK mints a fresh UUID for each one automatically. That is the right default: it means an internal retry can never double-write.

To retry the same logical operation — where a duplicate would be wrong — pin a key with withIdempotencyKey(). It returns a derived client and leaves the original untouched:

const key = crypto.randomUUID();
for (let attempt = 0; attempt < 3; attempt++) {
  try {
    return await chatly.withIdempotencyKey(key).contacts.upsert(body);
  } catch {
    await backoff(attempt);
  }
}

Two writes with the same key and the same body are one accepted operation and the cached response is replayed. Two writes with the same key and a different body return 409. Use a deterministic key — ${jobId}_${attempt} — when you want to dedupe across your own retry loop.

Info — The SDK does not retry for you

There is no built-in retry policy. A 5xx, a 429 or a network error throws; retrying is your loop, and withIdempotencyKey() is what makes that loop safe.

Webhook verification

import { verifyWebhookSignature } from '@livechat/sdk-node';
import express from 'express';

const app = express();

app.post(
  '/webhooks/chatly',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = verifyWebhookSignature({
      secret: process.env.CHATLY_WEBHOOK_SECRET!,      // whsec_…
      signatureHeader: req.header('x-livechat-signature')!,
      body: req.body,                                   // the RAW bytes
      toleranceSeconds: 300,
    });
    if (!ok) return res.status(400).send('bad signature');

    const event = JSON.parse(req.body.toString('utf8'));
    // handle event...
    return res.status(200).send('ok');
  },
);

VerifyWebhookSignatureInput

Name

Type

Description

body (required)

string | Uint8Array

The exact bytes we POSTed. Re-serialising parsed JSON reorders keys and changes whitespace, and the digest will not match.

signatureHeader (required)

string

The x-livechat-signature value: t=<unix seconds>,v1=<hex>.

secret (required)

string

The endpoint's whsec_… signing secret.

toleranceSeconds

number

How far t may be from now before the delivery is treated as a replay. Exported as WEBHOOK_SIGNATURE_TOLERANCE_SECONDS. Pass Infinity only when re-verifying an archived delivery. Default: 300.

nowSeconds

number

Escape hatch for tests. Unix seconds.

v1 is the lowercase hex HMAC-SHA-256 of <t>.<rawBody> under the endpoint's signing secret — the timestamp is bound into the digest, which is what makes the replay window enforceable. Signing the body alone rejects every genuine delivery. The comparison uses Node's timingSafeEqual, after a length check, so a malformed header returns false rather than throwing into a handler that forgot try/catch.

Deliveries also carry x-livechat-event (the event type) and x-livechat-timestamp (the same t). See Webhooks.

Identity Verification helper

import { chatlyIdentityHash } from '@livechat/sdk-node';

const userHash = chatlyIdentityHash(
  process.env.CHATLY_IDV_SECRET!,
  user.id,
);

HMAC-SHA256(secret, externalId), hex-encoded — the backend half of the handshake. Hand userHash to the browser and pass it to Chatly.identify({ externalId, userHash }); the secret itself must never leave your server. Throws if either argument is empty. See Identity Verification.

Error model

A non-2xx response throws a plain Error whose message is livechat <status>: <response body>. There is no typed error class, no isRateLimited() helper, and no parsed code / requestId on the thrown object — read the status and the JSON body out of the message, or wrap fetchFn if you need structured handling.

try {
  await chatly.contacts.upsert({ email: '[email protected]' });
} catch (err) {
  console.error(err instanceof Error ? err.message : err);
}

The response body itself is the API's standard error envelope. See Errors + idempotency for the code list.

TypeScript

The package exports ChatlyClient, verifyWebhookSignature, chatlyIdentityHash, WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, and the types ClientOptions, PageQuery, Page<T>, ContactInput, MessageInput, MessageReactions, WidgetFeatureConfig and VerifyWebhookSignatureInput.

Self-hosting

Point apiUrl at your private host. No other changes required:

const chatly = new ChatlyClient({
  apiUrl: 'https://chatly.internal.acme.com',
  apiKey: process.env.CHATLY_API_KEY!,
});

Source

github.com/livechat/platform — packages/sdk-node.

Was this page helpful?