@letsbot/sdk 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LetsBot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,304 @@
1
- # Temporary Holding Version
1
+ # LetsBot TypeScript SDK (`@letsbot/sdk`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Official TypeScript/Node client for the LetsBot developer API (`https://letsbot.net/api/v1`).
4
+
5
+ - Zero runtime dependencies — global `fetch` (Node 18+), `node:crypto`.
6
+ - ESM and CommonJS builds, full types generated from the OpenAPI spec.
7
+ - Retries with exponential backoff and full jitter, honouring `Retry-After`.
8
+ - An `Idempotency-Key` on every send, so a retried send is delivered at most once.
9
+ - A client-side token bucket (default 1000 requests/minute, the API's per-channel limit).
10
+ - One error type, `LetsBotApiError`, for every refusal — including the ones the API sends with HTTP 200/202.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @letsbot/sdk
16
+ ```
17
+
18
+ License: MIT.
19
+
20
+ ## Quick start
21
+
22
+ ```ts
23
+ import { LetsBot } from '@letsbot/sdk';
24
+
25
+ const letsbot = new LetsBot({ apiKey: process.env.LETSBOT_API_KEY! }); // the channel's lb_api_key
26
+
27
+ const sent = await letsbot.messages.send({ phone: '966501234567', body: 'Your order has shipped.' });
28
+ console.log(sent.success);
29
+ ```
30
+
31
+ The API key is the channel's `lb_api_key` (panel → API → Authentication data). It is **not** the Zapier key
32
+ (`lbz_…`). The workspace needs an active subscription with the API Access add-on, or an active trial.
33
+
34
+ ### Resources
35
+
36
+ Every endpoint in the spec is available, grouped like the API:
37
+
38
+ | Resource | Examples |
39
+ |---|---|
40
+ | `messages` | `send`, `reply`, `react`, `forward`, `sendList`, `sendDisappearing`, `get`, `fetch`, `delete` |
41
+ | `media` | `sendImage`, `sendFile`, `sendVideo`, `sendAudio`, `sendVoiceNote`, `sendLocation`, `sendContact`, `shareCatalog` |
42
+ | `interactive` | `sendButtons`, `sendButtonsWithImage`, `sendButtonsTemplate…`, `sendCarousel` |
43
+ | `templates` | `send`, `sendSimple`, `sendWithImage`, `sendWithDocument`, `sendWithVideo` (official channels only) |
44
+ | `chats` | `list`, `conversation`, `read`, `archive`, `mute`, `pin`, `typing`, `checkPhone`, … |
45
+ | `contacts` | `list`, `get`, `listLabels`, `addLabel`, `listModerators`, `assignModerator` |
46
+ | `groups`, `invites` | `create`, `list`, `addParticipants`, `rename`, `getCode`, `sendLink`, … |
47
+ | `user`, `instance`, `session`, `products` | profile, presence, status, QR, catalog |
48
+ | `archive` | `list` (one page), `iterate` (every page) |
49
+
50
+ ### CRM (`LetsBotCrm`)
51
+
52
+ The CRM API lives on the workspace's own domain (`https://{workspace}/api/v1/crm`) and takes a workspace API key
53
+ (`lbz_…`, Panel → Zapier → API keys) with the **crm** scope ticked. The key acts as the workspace owner.
54
+
55
+ ```ts
56
+ import { LetsBotCrm, verifyCrmWebhookSignature } from '@letsbot/sdk';
57
+
58
+ const crm = new LetsBotCrm({ apiKey: process.env.LETSBOT_CRM_KEY!, workspace: 'mystore.letsbot.net' });
59
+
60
+ const { data: contact } = await crm.contacts.create({ phone: '966501234567', name: 'Laila' });
61
+ const { data: deal } = await crm.deals.create({ title: 'Gift boxes', amount: 8750, contact_id: contact.id });
62
+ await crm.deals.moveStage({ id: deal.id, stage_id: 4 });
63
+ await crm.deals.addNote({ id: deal.id, body: 'Asked for a discount.' });
64
+
65
+ for await (const won of crm.iterate('crmListDeals', { status: 'won', sort: 'updated_at', dir: 'desc' })) {
66
+ console.log(won.title, won.amount);
67
+ }
68
+ ```
69
+
70
+ | Resource | Methods |
71
+ |---|---|
72
+ | `contacts` | `list`, `schema`, `search`, `get`, `create`, `update`, `addNote` |
73
+ | `companies` | `list`, `schema`, `search`, `get`, `create`, `update`, `delete`, `linkContact`, `unlinkContact`, `addNote` |
74
+ | `deals` | `list`, `pipelines`, `lostReasons`, `search`, `get`, `create`, `update`, `delete`, `moveStage`, `markWon`, `markLost`, `addNote` |
75
+ | `tasks` | `list`, `get`, `create`, `update`, `delete`, `complete` |
76
+ | `activities`, `quotes`, `products` | `list`, `get`, `create` (+ `update`, `delete`, `schema` for quotes and products) |
77
+ | `customObjects` | `list`, `schema` (custom objects — 404 until the workspace switches them on) |
78
+ | `customRecords` | `list`, `search`, `get`, `create`, `update`, `delete` — pass the object `key`, e.g. `co_projects`; values by field key |
79
+ | `hooks` | `list`, `events`, `sample`, `subscribe`, `unsubscribe` (`CRM_WEBHOOK_EVENTS` lists all 24 events) |
80
+
81
+ Every method takes one object: path parameters (`id`, `contactId`) plus the query (reads) or the JSON body (writes).
82
+ Lists take `q`, `filters` (lists-engine JSON — see `…schema()`), `view`, `sort`, `dir`, `page`, `per_page` and answer
83
+ `{success, data, meta}`. Errors are `LetsBotApiError` with the envelope's `code` (`missing_scope`, `not_found`,
84
+ `validation_failed`, `already_exists` — a duplicate phone, with the existing contact in `error.body.data`,
85
+ `possible_duplicate` — a likely duplicate, read it with `crmPossibleDuplicate(error)` or repeat with
86
+ `allow_duplicate: true`, …). Reads
87
+ are retried on network errors and 5xx; writes are not. The client-side limit defaults to 120 requests/minute, the
88
+ API's per-key limit.
89
+
90
+ Webhook deliveries (panel → CRM → Settings → API & webhooks, or `crm.hooks.subscribe`) carry
91
+ `X-LetsBot-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.<raw body>">`:
92
+
93
+ ```ts
94
+ const valid = verifyCrmWebhookSignature(rawBody, req.headers['x-letsbot-signature'], subscriptionSecret);
95
+ ```
96
+
97
+ `invoice.created`, `form.submitted`, `deal.assigned` and `health.at_risk` deliveries carry an extra block next to `data`:
98
+ `invoice`, `form` (source, form, submission, answers), `assignment` (new / previous owner) and `health` (score,
99
+ previous, reasons; `object` says contact or company).
100
+
101
+ ```ts
102
+ const record = await crm.customRecords.create({ key: 'co_projects', values: { name: 'Riyadh fit-out', budget: 120000 } });
103
+
104
+ try {
105
+ await crm.contacts.create({ phone: '966501234567', email: 'laila@example.com' });
106
+ } catch (error) {
107
+ const dup = crmPossibleDuplicate(error);
108
+ if (!dup) throw error;
109
+ await crm.contacts.update({ id: dup.existingId, name: 'Laila' });
110
+ }
111
+ ```
112
+
113
+ Any operation can also be called by its `operationId` from the spec:
114
+
115
+ ```ts
116
+ await letsbot.call('sendButtonsAlias', { phone: '966501234567', body: 'Confirm?', title: 'Order', footer: 'LetsBot', buttons: [{ id: 'yes', title: 'Yes' }] });
117
+ const { data, status, headers, replayed } = await letsbot.callWithResponse('getChatList');
118
+ ```
119
+
120
+ ### Business / ERP (`LetsBotErp`)
121
+
122
+ The Business API lives on the workspace domain like the CRM API and needs a workspace key with the **erp** scope
123
+ ticked. Each area (inventory, sales, purchasing, expenses, accounting) answers 404 until the workspace switches it on.
124
+ Creates return drafts (or take the one step you call); nothing messages a customer.
125
+
126
+ ```ts
127
+ import { LetsBotErp, verifyErpWebhookSignature } from '@letsbot/sdk';
128
+
129
+ const erp = new LetsBotErp({ apiKey: process.env.LETSBOT_KEY!, workspace: 'mystore.letsbot.net' });
130
+
131
+ const { data: order } = await erp.salesOrders.create({ customer_id: 42, lines: [{ sku: 'RY-OUD-50', qty: 2 }] });
132
+ await erp.salesOrders.confirm({ id: order!.id! });
133
+
134
+ const { data: invoice } = await erp.invoices.create({ order_id: order!.id! });
135
+ await erp.invoices.issue({ id: invoice!.id! });
136
+ const { data: link } = await erp.invoices.pdfLink({ id: invoice!.id! }); // link.pdf_url once shareable
137
+
138
+ await erp.payments.record({ customer_id: 42, amount: 1150, method: 'bank_transfer', reference: 'TRX-1' });
139
+
140
+ for await (const level of erp.iterate('erpListStockLevels', { warehouse_id: 1 })) console.log(level.sku, level.available);
141
+
142
+ // Webhooks: invoice.issued, invoice.paid, invoice.cleared, invoice.rejected, credit_note.issued, payment.received,
143
+ // order.confirmed, stock.low, po.confirmed_by_supplier, expense.posted
144
+ const { data: hook } = await erp.hooks.subscribe({ url: 'https://example.com/letsbot', events: ['invoice.paid'] });
145
+ // on receipt: verifyErpWebhookSignature(rawBody, req.headers['x-letsbot-signature'], hook.secret)
146
+ ```
147
+
148
+ Every create (`salesOrders.create`, `invoices.create`, `invoices.createCreditNote`, `payments.record`,
149
+ `purchaseOrders.create`, `expenses.create`) sends an `Idempotency-Key` — pass `{ idempotencyKey: 'order-123' }` to
150
+ tie it to your own id — so a retry after a timeout never makes a second document.
151
+
152
+ ### Templates
153
+
154
+ ```ts
155
+ await letsbot.templates.send({
156
+ phone: '966501234567',
157
+ template_name: 'order_confirmation',
158
+ template_language: 'ar',
159
+ components: { body: ['Ahmed', 'ORD-12345', '150 SAR'] },
160
+ });
161
+ ```
162
+
163
+ ### Message archive
164
+
165
+ ```ts
166
+ for await (const message of letsbot.archive.iterate({ date_from: '2026-09-01', date_to: '2026-09-07', timezone: 'Asia/Riyadh' })) {
167
+ console.log(message.message_date, message.from_me, message.body);
168
+ }
169
+ ```
170
+
171
+ ## Errors
172
+
173
+ ```ts
174
+ import { LetsBotApiError, LetsBotConnectionError } from '@letsbot/sdk';
175
+
176
+ try {
177
+ await letsbot.messages.send({ phone: '966501234567', body: 'Hi' });
178
+ } catch (error) {
179
+ if (error instanceof LetsBotApiError) {
180
+ error.status; // HTTP status — can be 200 or 202, see below
181
+ error.body; // parsed JSON body
182
+ error.code; // error_code / code / error from the body, e.g. API_ADDON_REQUIRED
183
+ error.requestId; // the support reference on a 500 (API-XXXXXXXXXX), else undefined
184
+ } else if (error instanceof LetsBotConnectionError) {
185
+ // no HTTP response at all (DNS, reset, timeout), after retries
186
+ }
187
+ }
188
+ ```
189
+
190
+ **The HTTP status alone does not tell you whether a request worked.** Most endpoints answer a validation failure
191
+ with **HTTP 202** and `{"Status": 0, "Message": "..."}`, and pass the connection gateway's answer through with
192
+ HTTP 200 even when it says `{"success": false}`. The SDK treats both as failures and throws `LetsBotApiError`
193
+ (same rule the server uses to decide whether a send counted). Pass `throwOnLogicalFailure: false` to get those
194
+ bodies back instead.
195
+
196
+ The API has no request-id header; `requestId` is only filled from the `reference` of a 500.
197
+
198
+ ## Retries
199
+
200
+ - Exponential backoff with full jitter: base 500 ms, at most 3 retries (`retry: { maxRetries, baseDelayMs, maxDelayMs }`).
201
+ - `Retry-After` is honoured. A `Retry-After` longer than `retry.maxRetryAfterMs` (default 60 s — the workspace's daily
202
+ send limit can say hours) is not waited out: the error is thrown with `error.retryAfter` set.
203
+ - **429** is retried on every endpoint — it means nothing was done.
204
+ - **Network errors and 5xx** are retried only when repeating is safe: on GET requests, and on **send** endpoints
205
+ that carry an `Idempotency-Key`. Any other POST/DELETE (archive a chat, block a user, create a group, …) is never
206
+ repeated after it may have reached the server.
207
+ - **409 `idempotency_request_in_progress`** is retried with the same key.
208
+ - **409 `idempotency_outcome_unknown`** is never retried (`error.isOutcomeUnknown === true`): an earlier attempt with
209
+ that key died before its result was known. Check the message before sending again with a new key.
210
+ Expect this after a 5xx on a send: the server marks the key "outcome unknown" on any 5xx, so the retry under the
211
+ same key comes back as this 409 rather than risking a duplicate.
212
+
213
+ ## Idempotency
214
+
215
+ Every **send** endpoint (the 35 operations marked `x-letsbot-send` in the spec — `messages.send`, `templates.*`,
216
+ `media.send*`, `interactive.*`, `messages.forward`, `media.shareCatalog`, `invites.sendLink`, …) gets an
217
+ `Idempotency-Key` header: a fresh UUID per call by default, reused on every retry of that call. The server
218
+ keeps the result for 24 hours and replays it for the same key (`callWithResponse(...).replayed === true`).
219
+
220
+ - Pass your own key to stay idempotent across process restarts: `{ idempotencyKey: 'order-12345' }`.
221
+ - Pass `{ idempotencyKey: false }` to send without one — the SDK will then not retry that send after a network
222
+ error or 5xx.
223
+ - The server honours the header on send endpoints only, so the SDK does not send it anywhere else; a key given on
224
+ another endpoint is ignored and does not enable retries.
225
+
226
+ ## Rate limit
227
+
228
+ The client waits for a token before every attempt (default 1000 per minute, the API's per-channel limit). Use
229
+ one client per API key so the bucket sees all of that key's traffic. `rateLimit: { requestsPerMinute: 300 }` to
230
+ go slower, `rateLimit: false` to turn it off.
231
+
232
+ ## Webhooks (experimental — read this)
233
+
234
+ API webhooks (configured in the panel, API → Webhooks) are POSTed as `{"event": "<type>", "data": {...}}` with a
235
+ `Signature` header: the hex HMAC-SHA256 of the raw body. **The key is the literal string `Letsbot`, the same for
236
+ every LetsBot workspace**, and it is printed in this SDK. Anyone can therefore produce a valid signature:
237
+
238
+ ```ts
239
+ import { experimental_verifyWebhookSignature } from '@letsbot/sdk';
240
+
241
+ experimental_verifyWebhookSignature(rawBody, req.headers['signature']); // integrity only — NOT authentication
242
+ ```
243
+
244
+ A `true` does **not** prove the request came from LetsBot. To authenticate webhooks, add a header with a secret of
245
+ your own to the webhook in the panel and compare it on receipt (see `examples/webhook-receiver.ts`). Verify the
246
+ raw bytes you received, before `JSON.parse`: the server's JSON escapes `/` and non-ASCII characters, so a
247
+ re-serialised body will not match.
248
+
249
+ ## In-App Chat identity
250
+
251
+ LetsBot In-App Chat (the mobile SDKs) links a conversation to your signed-in user when the app calls
252
+ `identify(identityToken)`. The identity token is a short-lived JWT (HS256) that **your backend** signs with the app's
253
+ **Identity Secret** (panel → In-App Chat → your app). Never put the secret in the mobile app.
254
+
255
+ ```ts
256
+ import { createIdentityToken } from '@letsbot/sdk';
257
+
258
+ // e.g. GET /api/letsbot/identity-token — behind your own authentication
259
+ app.get('/api/letsbot/identity-token', requireUser, (req, res) => {
260
+ const token = createIdentityToken({
261
+ userId: req.user.id, // sub: stable id, ≤ 191 characters
262
+ secret: process.env.LETSBOT_IDENTITY_SECRET!,
263
+ expiresIn: '24h', // default; seconds or '90s' | '15m' | '24h' | '7d', max 7 days
264
+ name: req.user.name, // optional; claims win over fields the app sends
265
+ email: req.user.email, // optional
266
+ phone: req.user.phone, // optional, international format
267
+ });
268
+ res.set('Cache-Control', 'no-store').json({ token });
269
+ });
270
+ ```
271
+
272
+ Claims: `sub`, `iat`, `exp` (+ optional `name`, `email`, `phone`). LetsBot allows 60 s of clock skew and rejects a
273
+ token whose `exp` is more than 7 days after `iat`. An expired token makes `identify` fail with `identity_expired` —
274
+ the app should fetch a fresh one from this endpoint and retry; a bad signature gives `identity_invalid`.
275
+ `createIdentityToken` throws `TypeError` / `RangeError` on an empty secret, an empty or over-long `userId`, or a
276
+ lifetime over 7 days. Do not log the token or the secret. Examples for PHP, Python, Go, Ruby and .NET:
277
+ `https://github.com/Lets-Bot/letsbot-identity-examples`.
278
+
279
+ ## Development
280
+
281
+ ```bash
282
+ npm install
283
+ npm run build # tsup → dist/ (ESM + CJS + .d.ts)
284
+ npx tsc --noEmit -p . # typecheck src, test, examples
285
+ npx vitest run test/retry.test.ts # one spec file at a time
286
+ npm run check:drift # fails when anything generated is stale
287
+ ```
288
+
289
+ The spec lives in the LetsBot workspace at `packages/api/openapi/openapi.yaml`; this repository keeps a copy in
290
+ `openapi/openapi.yaml` so it builds on its own. Inside the workspace:
291
+
292
+ - `npm run spec:sync` — copy the workspace spec here and regenerate `src/generated/` (types via
293
+ `openapi-typescript`, the operation table via `scripts/gen-operations.mjs`).
294
+ - `npm run spec:json` — regenerate `packages/api/openapi/openapi.json` from the YAML (letsbot's
295
+ `OpenApiRouteParityTest` reads the JSON twin).
296
+ - `npm run check:drift` — vendored copy == workspace spec, generated types and operation table are fresh, and the
297
+ JSON twin matches the YAML. Outside the workspace the two workspace checks are skipped. Point
298
+ `LETSBOT_API_SPEC` at another copy of the spec to check against it.
299
+
300
+ ## Publishing
301
+
302
+ Published to npm as `@letsbot/sdk` (public, MIT). Bump `version` in `package.json` and `SDK_VERSION` in
303
+ `src/client.ts` together, move the changelog entries under the new version, then `npm publish --access public`
304
+ (`prepublishOnly` builds and runs the tests).