@livechat/sdk-node
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
@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 bothfetchand thenode:cryptobuiltin (Deno and Bun do; Vercel Edge does not).
Install
pnpm
pnpm add @livechat/sdk-nodenpm
npm install @livechat/sdk-nodeyarn
yarn add @livechat/sdk-nodebun
bun add @livechat/sdk-nodeQuick 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 |
|---|---|---|
|
| A workspace API key ( |
|
| 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: |
|
| Per-request timeout. AbortController-backed. Default: |
|
| Override the global |
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| The same route narrowed to |
|
|
|
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 fromconversations.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 |
|---|---|---|
|
| The exact bytes we POSTed. Re-serialising parsed JSON reorders keys and changes whitespace, and the digest will not match. |
|
| The |
|
| The endpoint's |
|
| How far |
|
| 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!,
});