@letsbot/sdk 0.0.0-stage → 0.2.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,252 @@
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
+ | `hooks` | `list`, `events`, `sample`, `subscribe`, `unsubscribe` |
78
+
79
+ Every method takes one object: path parameters (`id`, `contactId`) plus the query (reads) or the JSON body (writes).
80
+ Lists take `q`, `filters` (lists-engine JSON — see `…schema()`), `view`, `sort`, `dir`, `page`, `per_page` and answer
81
+ `{success, data, meta}`. Errors are `LetsBotApiError` with the envelope's `code` (`missing_scope`, `not_found`,
82
+ `validation_failed`, `already_exists` — a duplicate phone, with the existing contact in `error.body.data`, …). Reads
83
+ are retried on network errors and 5xx; writes are not. The client-side limit defaults to 120 requests/minute, the
84
+ API's per-key limit.
85
+
86
+ Webhook deliveries (panel → CRM → Settings → API & webhooks, or `crm.hooks.subscribe`) carry
87
+ `X-LetsBot-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.<raw body>">`:
88
+
89
+ ```ts
90
+ const valid = verifyCrmWebhookSignature(rawBody, req.headers['x-letsbot-signature'], subscriptionSecret);
91
+ ```
92
+
93
+ Any operation can also be called by its `operationId` from the spec:
94
+
95
+ ```ts
96
+ await letsbot.call('sendButtonsAlias', { phone: '966501234567', body: 'Confirm?', title: 'Order', footer: 'LetsBot', buttons: [{ id: 'yes', title: 'Yes' }] });
97
+ const { data, status, headers, replayed } = await letsbot.callWithResponse('getChatList');
98
+ ```
99
+
100
+ ### Templates
101
+
102
+ ```ts
103
+ await letsbot.templates.send({
104
+ phone: '966501234567',
105
+ template_name: 'order_confirmation',
106
+ template_language: 'ar',
107
+ components: { body: ['Ahmed', 'ORD-12345', '150 SAR'] },
108
+ });
109
+ ```
110
+
111
+ ### Message archive
112
+
113
+ ```ts
114
+ for await (const message of letsbot.archive.iterate({ date_from: '2026-09-01', date_to: '2026-09-07', timezone: 'Asia/Riyadh' })) {
115
+ console.log(message.message_date, message.from_me, message.body);
116
+ }
117
+ ```
118
+
119
+ ## Errors
120
+
121
+ ```ts
122
+ import { LetsBotApiError, LetsBotConnectionError } from '@letsbot/sdk';
123
+
124
+ try {
125
+ await letsbot.messages.send({ phone: '966501234567', body: 'Hi' });
126
+ } catch (error) {
127
+ if (error instanceof LetsBotApiError) {
128
+ error.status; // HTTP status — can be 200 or 202, see below
129
+ error.body; // parsed JSON body
130
+ error.code; // error_code / code / error from the body, e.g. API_ADDON_REQUIRED
131
+ error.requestId; // the support reference on a 500 (API-XXXXXXXXXX), else undefined
132
+ } else if (error instanceof LetsBotConnectionError) {
133
+ // no HTTP response at all (DNS, reset, timeout), after retries
134
+ }
135
+ }
136
+ ```
137
+
138
+ **The HTTP status alone does not tell you whether a request worked.** Most endpoints answer a validation failure
139
+ with **HTTP 202** and `{"Status": 0, "Message": "..."}`, and pass the connection gateway's answer through with
140
+ HTTP 200 even when it says `{"success": false}`. The SDK treats both as failures and throws `LetsBotApiError`
141
+ (same rule the server uses to decide whether a send counted). Pass `throwOnLogicalFailure: false` to get those
142
+ bodies back instead.
143
+
144
+ The API has no request-id header; `requestId` is only filled from the `reference` of a 500.
145
+
146
+ ## Retries
147
+
148
+ - Exponential backoff with full jitter: base 500 ms, at most 3 retries (`retry: { maxRetries, baseDelayMs, maxDelayMs }`).
149
+ - `Retry-After` is honoured. A `Retry-After` longer than `retry.maxRetryAfterMs` (default 60 s — the workspace's daily
150
+ send limit can say hours) is not waited out: the error is thrown with `error.retryAfter` set.
151
+ - **429** is retried on every endpoint — it means nothing was done.
152
+ - **Network errors and 5xx** are retried only when repeating is safe: on GET requests, and on **send** endpoints
153
+ that carry an `Idempotency-Key`. Any other POST/DELETE (archive a chat, block a user, create a group, …) is never
154
+ repeated after it may have reached the server.
155
+ - **409 `idempotency_request_in_progress`** is retried with the same key.
156
+ - **409 `idempotency_outcome_unknown`** is never retried (`error.isOutcomeUnknown === true`): an earlier attempt with
157
+ that key died before its result was known. Check the message before sending again with a new key.
158
+ Expect this after a 5xx on a send: the server marks the key "outcome unknown" on any 5xx, so the retry under the
159
+ same key comes back as this 409 rather than risking a duplicate.
160
+
161
+ ## Idempotency
162
+
163
+ Every **send** endpoint (the 35 operations marked `x-letsbot-send` in the spec — `messages.send`, `templates.*`,
164
+ `media.send*`, `interactive.*`, `messages.forward`, `media.shareCatalog`, `invites.sendLink`, …) gets an
165
+ `Idempotency-Key` header: a fresh UUID per call by default, reused on every retry of that call. The server
166
+ keeps the result for 24 hours and replays it for the same key (`callWithResponse(...).replayed === true`).
167
+
168
+ - Pass your own key to stay idempotent across process restarts: `{ idempotencyKey: 'order-12345' }`.
169
+ - Pass `{ idempotencyKey: false }` to send without one — the SDK will then not retry that send after a network
170
+ error or 5xx.
171
+ - The server honours the header on send endpoints only, so the SDK does not send it anywhere else; a key given on
172
+ another endpoint is ignored and does not enable retries.
173
+
174
+ ## Rate limit
175
+
176
+ The client waits for a token before every attempt (default 1000 per minute, the API's per-channel limit). Use
177
+ one client per API key so the bucket sees all of that key's traffic. `rateLimit: { requestsPerMinute: 300 }` to
178
+ go slower, `rateLimit: false` to turn it off.
179
+
180
+ ## Webhooks (experimental — read this)
181
+
182
+ API webhooks (configured in the panel, API → Webhooks) are POSTed as `{"event": "<type>", "data": {...}}` with a
183
+ `Signature` header: the hex HMAC-SHA256 of the raw body. **The key is the literal string `Letsbot`, the same for
184
+ every LetsBot workspace**, and it is printed in this SDK. Anyone can therefore produce a valid signature:
185
+
186
+ ```ts
187
+ import { experimental_verifyWebhookSignature } from '@letsbot/sdk';
188
+
189
+ experimental_verifyWebhookSignature(rawBody, req.headers['signature']); // integrity only — NOT authentication
190
+ ```
191
+
192
+ A `true` does **not** prove the request came from LetsBot. To authenticate webhooks, add a header with a secret of
193
+ your own to the webhook in the panel and compare it on receipt (see `examples/webhook-receiver.ts`). Verify the
194
+ raw bytes you received, before `JSON.parse`: the server's JSON escapes `/` and non-ASCII characters, so a
195
+ re-serialised body will not match.
196
+
197
+ ## In-App Chat identity
198
+
199
+ LetsBot In-App Chat (the mobile SDKs) links a conversation to your signed-in user when the app calls
200
+ `identify(identityToken)`. The identity token is a short-lived JWT (HS256) that **your backend** signs with the app's
201
+ **Identity Secret** (panel → In-App Chat → your app). Never put the secret in the mobile app.
202
+
203
+ ```ts
204
+ import { createIdentityToken } from '@letsbot/sdk';
205
+
206
+ // e.g. GET /api/letsbot/identity-token — behind your own authentication
207
+ app.get('/api/letsbot/identity-token', requireUser, (req, res) => {
208
+ const token = createIdentityToken({
209
+ userId: req.user.id, // sub: stable id, ≤ 191 characters
210
+ secret: process.env.LETSBOT_IDENTITY_SECRET!,
211
+ expiresIn: '24h', // default; seconds or '90s' | '15m' | '24h' | '7d', max 7 days
212
+ name: req.user.name, // optional; claims win over fields the app sends
213
+ email: req.user.email, // optional
214
+ phone: req.user.phone, // optional, international format
215
+ });
216
+ res.set('Cache-Control', 'no-store').json({ token });
217
+ });
218
+ ```
219
+
220
+ Claims: `sub`, `iat`, `exp` (+ optional `name`, `email`, `phone`). LetsBot allows 60 s of clock skew and rejects a
221
+ token whose `exp` is more than 7 days after `iat`. An expired token makes `identify` fail with `identity_expired` —
222
+ the app should fetch a fresh one from this endpoint and retry; a bad signature gives `identity_invalid`.
223
+ `createIdentityToken` throws `TypeError` / `RangeError` on an empty secret, an empty or over-long `userId`, or a
224
+ lifetime over 7 days. Do not log the token or the secret. Examples for PHP, Python, Go, Ruby and .NET:
225
+ `https://github.com/Lets-Bot/letsbot-identity-examples`.
226
+
227
+ ## Development
228
+
229
+ ```bash
230
+ npm install
231
+ npm run build # tsup → dist/ (ESM + CJS + .d.ts)
232
+ npx tsc --noEmit -p . # typecheck src, test, examples
233
+ npx vitest run test/retry.test.ts # one spec file at a time
234
+ npm run check:drift # fails when anything generated is stale
235
+ ```
236
+
237
+ The spec lives in the LetsBot workspace at `packages/api/openapi/openapi.yaml`; this repository keeps a copy in
238
+ `openapi/openapi.yaml` so it builds on its own. Inside the workspace:
239
+
240
+ - `npm run spec:sync` — copy the workspace spec here and regenerate `src/generated/` (types via
241
+ `openapi-typescript`, the operation table via `scripts/gen-operations.mjs`).
242
+ - `npm run spec:json` — regenerate `packages/api/openapi/openapi.json` from the YAML (letsbot's
243
+ `OpenApiRouteParityTest` reads the JSON twin).
244
+ - `npm run check:drift` — vendored copy == workspace spec, generated types and operation table are fresh, and the
245
+ JSON twin matches the YAML. Outside the workspace the two workspace checks are skipped. Point
246
+ `LETSBOT_API_SPEC` at another copy of the spec to check against it.
247
+
248
+ ## Publishing
249
+
250
+ Published to npm as `@letsbot/sdk` (public, MIT). Bump `version` in `package.json` and `SDK_VERSION` in
251
+ `src/client.ts` together, move the changelog entries under the new version, then `npm publish --access public`
252
+ (`prepublishOnly` builds and runs the tests).