@letsbot/sdk
v0.2.0
Published
Official TypeScript/Node SDK for the LetsBot developer API (/api/v1).
Maintainers
Readme
LetsBot TypeScript SDK (@letsbot/sdk)
Official TypeScript/Node client for the LetsBot developer API (https://letsbot.net/api/v1).
- Zero runtime dependencies — global
fetch(Node 18+),node:crypto. - ESM and CommonJS builds, full types generated from the OpenAPI spec.
- Retries with exponential backoff and full jitter, honouring
Retry-After. - An
Idempotency-Keyon every send, so a retried send is delivered at most once. - A client-side token bucket (default 1000 requests/minute, the API's per-channel limit).
- One error type,
LetsBotApiError, for every refusal — including the ones the API sends with HTTP 200/202.
Install
npm install @letsbot/sdkLicense: MIT.
Quick start
import { LetsBot } from '@letsbot/sdk';
const letsbot = new LetsBot({ apiKey: process.env.LETSBOT_API_KEY! }); // the channel's lb_api_key
const sent = await letsbot.messages.send({ phone: '966501234567', body: 'Your order has shipped.' });
console.log(sent.success);The API key is the channel's lb_api_key (panel → API → Authentication data). It is not the Zapier key
(lbz_…). The workspace needs an active subscription with the API Access add-on, or an active trial.
Resources
Every endpoint in the spec is available, grouped like the API:
| Resource | Examples |
|---|---|
| messages | send, reply, react, forward, sendList, sendDisappearing, get, fetch, delete |
| media | sendImage, sendFile, sendVideo, sendAudio, sendVoiceNote, sendLocation, sendContact, shareCatalog |
| interactive | sendButtons, sendButtonsWithImage, sendButtonsTemplate…, sendCarousel |
| templates | send, sendSimple, sendWithImage, sendWithDocument, sendWithVideo (official channels only) |
| chats | list, conversation, read, archive, mute, pin, typing, checkPhone, … |
| contacts | list, get, listLabels, addLabel, listModerators, assignModerator |
| groups, invites | create, list, addParticipants, rename, getCode, sendLink, … |
| user, instance, session, products | profile, presence, status, QR, catalog |
| archive | list (one page), iterate (every page) |
CRM (LetsBotCrm)
The CRM API lives on the workspace's own domain (https://{workspace}/api/v1/crm) and takes a workspace API key
(lbz_…, Panel → Zapier → API keys) with the crm scope ticked. The key acts as the workspace owner.
import { LetsBotCrm, verifyCrmWebhookSignature } from '@letsbot/sdk';
const crm = new LetsBotCrm({ apiKey: process.env.LETSBOT_CRM_KEY!, workspace: 'mystore.letsbot.net' });
const { data: contact } = await crm.contacts.create({ phone: '966501234567', name: 'Laila' });
const { data: deal } = await crm.deals.create({ title: 'Gift boxes', amount: 8750, contact_id: contact.id });
await crm.deals.moveStage({ id: deal.id, stage_id: 4 });
await crm.deals.addNote({ id: deal.id, body: 'Asked for a discount.' });
for await (const won of crm.iterate('crmListDeals', { status: 'won', sort: 'updated_at', dir: 'desc' })) {
console.log(won.title, won.amount);
}| Resource | Methods |
|---|---|
| contacts | list, schema, search, get, create, update, addNote |
| companies | list, schema, search, get, create, update, delete, linkContact, unlinkContact, addNote |
| deals | list, pipelines, lostReasons, search, get, create, update, delete, moveStage, markWon, markLost, addNote |
| tasks | list, get, create, update, delete, complete |
| activities, quotes, products | list, get, create (+ update, delete, schema for quotes and products) |
| hooks | list, events, sample, subscribe, unsubscribe |
Every method takes one object: path parameters (id, contactId) plus the query (reads) or the JSON body (writes).
Lists take q, filters (lists-engine JSON — see …schema()), view, sort, dir, page, per_page and answer
{success, data, meta}. Errors are LetsBotApiError with the envelope's code (missing_scope, not_found,
validation_failed, already_exists — a duplicate phone, with the existing contact in error.body.data, …). Reads
are retried on network errors and 5xx; writes are not. The client-side limit defaults to 120 requests/minute, the
API's per-key limit.
Webhook deliveries (panel → CRM → Settings → API & webhooks, or crm.hooks.subscribe) carry
X-LetsBot-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.<raw body>">:
const valid = verifyCrmWebhookSignature(rawBody, req.headers['x-letsbot-signature'], subscriptionSecret);Any operation can also be called by its operationId from the spec:
await letsbot.call('sendButtonsAlias', { phone: '966501234567', body: 'Confirm?', title: 'Order', footer: 'LetsBot', buttons: [{ id: 'yes', title: 'Yes' }] });
const { data, status, headers, replayed } = await letsbot.callWithResponse('getChatList');Templates
await letsbot.templates.send({
phone: '966501234567',
template_name: 'order_confirmation',
template_language: 'ar',
components: { body: ['Ahmed', 'ORD-12345', '150 SAR'] },
});Message archive
for await (const message of letsbot.archive.iterate({ date_from: '2026-09-01', date_to: '2026-09-07', timezone: 'Asia/Riyadh' })) {
console.log(message.message_date, message.from_me, message.body);
}Errors
import { LetsBotApiError, LetsBotConnectionError } from '@letsbot/sdk';
try {
await letsbot.messages.send({ phone: '966501234567', body: 'Hi' });
} catch (error) {
if (error instanceof LetsBotApiError) {
error.status; // HTTP status — can be 200 or 202, see below
error.body; // parsed JSON body
error.code; // error_code / code / error from the body, e.g. API_ADDON_REQUIRED
error.requestId; // the support reference on a 500 (API-XXXXXXXXXX), else undefined
} else if (error instanceof LetsBotConnectionError) {
// no HTTP response at all (DNS, reset, timeout), after retries
}
}The HTTP status alone does not tell you whether a request worked. Most endpoints answer a validation failure
with HTTP 202 and {"Status": 0, "Message": "..."}, and pass the connection gateway's answer through with
HTTP 200 even when it says {"success": false}. The SDK treats both as failures and throws LetsBotApiError
(same rule the server uses to decide whether a send counted). Pass throwOnLogicalFailure: false to get those
bodies back instead.
The API has no request-id header; requestId is only filled from the reference of a 500.
Retries
- Exponential backoff with full jitter: base 500 ms, at most 3 retries (
retry: { maxRetries, baseDelayMs, maxDelayMs }). Retry-Afteris honoured. ARetry-Afterlonger thanretry.maxRetryAfterMs(default 60 s — the workspace's daily send limit can say hours) is not waited out: the error is thrown witherror.retryAfterset.- 429 is retried on every endpoint — it means nothing was done.
- Network errors and 5xx are retried only when repeating is safe: on GET requests, and on send endpoints
that carry an
Idempotency-Key. Any other POST/DELETE (archive a chat, block a user, create a group, …) is never repeated after it may have reached the server. - 409
idempotency_request_in_progressis retried with the same key. - 409
idempotency_outcome_unknownis never retried (error.isOutcomeUnknown === true): an earlier attempt with that key died before its result was known. Check the message before sending again with a new key. Expect this after a 5xx on a send: the server marks the key "outcome unknown" on any 5xx, so the retry under the same key comes back as this 409 rather than risking a duplicate.
Idempotency
Every send endpoint (the 35 operations marked x-letsbot-send in the spec — messages.send, templates.*,
media.send*, interactive.*, messages.forward, media.shareCatalog, invites.sendLink, …) gets an
Idempotency-Key header: a fresh UUID per call by default, reused on every retry of that call. The server
keeps the result for 24 hours and replays it for the same key (callWithResponse(...).replayed === true).
- Pass your own key to stay idempotent across process restarts:
{ idempotencyKey: 'order-12345' }. - Pass
{ idempotencyKey: false }to send without one — the SDK will then not retry that send after a network error or 5xx. - The server honours the header on send endpoints only, so the SDK does not send it anywhere else; a key given on another endpoint is ignored and does not enable retries.
Rate limit
The client waits for a token before every attempt (default 1000 per minute, the API's per-channel limit). Use
one client per API key so the bucket sees all of that key's traffic. rateLimit: { requestsPerMinute: 300 } to
go slower, rateLimit: false to turn it off.
Webhooks (experimental — read this)
API webhooks (configured in the panel, API → Webhooks) are POSTed as {"event": "<type>", "data": {...}} with a
Signature header: the hex HMAC-SHA256 of the raw body. The key is the literal string Letsbot, the same for
every LetsBot workspace, and it is printed in this SDK. Anyone can therefore produce a valid signature:
import { experimental_verifyWebhookSignature } from '@letsbot/sdk';
experimental_verifyWebhookSignature(rawBody, req.headers['signature']); // integrity only — NOT authenticationA true does not prove the request came from LetsBot. To authenticate webhooks, add a header with a secret of
your own to the webhook in the panel and compare it on receipt (see examples/webhook-receiver.ts). Verify the
raw bytes you received, before JSON.parse: the server's JSON escapes / and non-ASCII characters, so a
re-serialised body will not match.
In-App Chat identity
LetsBot In-App Chat (the mobile SDKs) links a conversation to your signed-in user when the app calls
identify(identityToken). The identity token is a short-lived JWT (HS256) that your backend signs with the app's
Identity Secret (panel → In-App Chat → your app). Never put the secret in the mobile app.
import { createIdentityToken } from '@letsbot/sdk';
// e.g. GET /api/letsbot/identity-token — behind your own authentication
app.get('/api/letsbot/identity-token', requireUser, (req, res) => {
const token = createIdentityToken({
userId: req.user.id, // sub: stable id, ≤ 191 characters
secret: process.env.LETSBOT_IDENTITY_SECRET!,
expiresIn: '24h', // default; seconds or '90s' | '15m' | '24h' | '7d', max 7 days
name: req.user.name, // optional; claims win over fields the app sends
email: req.user.email, // optional
phone: req.user.phone, // optional, international format
});
res.set('Cache-Control', 'no-store').json({ token });
});Claims: sub, iat, exp (+ optional name, email, phone). LetsBot allows 60 s of clock skew and rejects a
token whose exp is more than 7 days after iat. An expired token makes identify fail with identity_expired —
the app should fetch a fresh one from this endpoint and retry; a bad signature gives identity_invalid.
createIdentityToken throws TypeError / RangeError on an empty secret, an empty or over-long userId, or a
lifetime over 7 days. Do not log the token or the secret. Examples for PHP, Python, Go, Ruby and .NET:
https://github.com/Lets-Bot/letsbot-identity-examples.
Development
npm install
npm run build # tsup → dist/ (ESM + CJS + .d.ts)
npx tsc --noEmit -p . # typecheck src, test, examples
npx vitest run test/retry.test.ts # one spec file at a time
npm run check:drift # fails when anything generated is staleThe spec lives in the LetsBot workspace at packages/api/openapi/openapi.yaml; this repository keeps a copy in
openapi/openapi.yaml so it builds on its own. Inside the workspace:
npm run spec:sync— copy the workspace spec here and regeneratesrc/generated/(types viaopenapi-typescript, the operation table viascripts/gen-operations.mjs).npm run spec:json— regeneratepackages/api/openapi/openapi.jsonfrom the YAML (letsbot'sOpenApiRouteParityTestreads the JSON twin).npm run check:drift— vendored copy == workspace spec, generated types and operation table are fresh, and the JSON twin matches the YAML. Outside the workspace the two workspace checks are skipped. PointLETSBOT_API_SPECat another copy of the spec to check against it.
Publishing
Published to npm as @letsbot/sdk (public, MIT). Bump version in package.json and SDK_VERSION in
src/client.ts together, move the changelog entries under the new version, then npm publish --access public
(prepublishOnly builds and runs the tests).
