@labyrinthe/connect 0.0.0-stage → 1.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/README.md CHANGED
@@ -1,3 +1,809 @@
1
- # Temporary Holding Version
1
+ # Labyrinthe Connect — Node.js / TypeScript 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
+ [![npm](https://img.shields.io/npm/v/@labyrinthe/connect.svg)](https://www.npmjs.com/package/@labyrinthe/connect)
4
+ [![CI](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/actions/workflows/node.yml/badge.svg)](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/actions/workflows/node.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ ![Node](https://img.shields.io/badge/node-%E2%89%A5%2018-339933)
7
+
8
+ Official JavaScript / TypeScript client for [Labyrinthe Connect](https://labyrinthe-rdc.com), the SMS and OTP
9
+ platform for the DR Congo and Central Africa: send SMS, follow their delivery and verify phone numbers with one-time
10
+ codes (OTP).
11
+
12
+ *Version française : [README.fr.md](README.fr.md).*
13
+
14
+ - **Fully typed**: camelCase objects, `Date`s, string-literal enums, one error class per error code.
15
+ - **Never a double SMS**: every send carries an `Idempotency-Key`; automatic retries only happen when a replay is safe.
16
+ - **Zero runtime dependency**, ESM + CommonJS, Node.js 18+, Vercel / Next.js Edge, Cloudflare Workers, Deno and Bun.
17
+ - **Secure by default**: your API key, OTP codes and full phone numbers never reach logs, `console.log()` or
18
+ `JSON.stringify()`; webhooks are verified in constant time on the raw body.
19
+ - Webhook helpers for **Express** and **Next.js** (App Router, and any Fetch `Request` / `Response` runtime).
20
+
21
+ Version **1.2.0**, built against the API specification **1.3.0**
22
+ ([`openapi.yaml`](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/blob/main/openapi/openapi.yaml)).
23
+ What changed: [CHANGELOG](CHANGELOG.md).
24
+
25
+ ## Contents
26
+
27
+ 1. [Requirements](#requirements)
28
+ 2. [Installation](#installation)
29
+ 3. [Quick start](#quick-start)
30
+ 4. [Configuration](#configuration)
31
+ 5. [Sending SMS](#sending-sms)
32
+ 6. [Balance and Sender IDs](#balance-and-sender-ids)
33
+ 7. [OTP: verify a phone number](#otp-verify-a-phone-number)
34
+ 8. [Webhooks](#webhooks)
35
+ 9. [Errors](#errors)
36
+ 10. [Retries and idempotency](#retries-and-idempotency)
37
+ 11. [Test mode, logging, request ids and types](#test-mode-logging-request-ids-and-types)
38
+ 12. [Without the package: raw HTTP](#without-the-package-raw-http)
39
+ 13. [Upgrading](#upgrading)
40
+ 14. [Contributing](#contributing) · [License](#license)
41
+
42
+ ## Requirements
43
+
44
+ | Runtime | Support |
45
+ |---|---|
46
+ | Node.js 18, 20, 22, 24 (`engines: >=18`) | ✅ global `fetch`; on Node 18, Web Crypto is loaded from the crypto module at first use |
47
+ | Vercel Edge, Next.js Edge routes | ✅ |
48
+ | Cloudflare Workers | ✅ no `nodejs_compat` flag needed |
49
+ | Deno, Bun | ✅ (`npm:@labyrinthe/connect` on Deno) |
50
+ | Browsers | ❌ refused by default: the API key must stay server-side |
51
+
52
+ Types are bundled for ESM (`.d.ts`) and CommonJS (`.d.cts`). `express` (≥ 4.17) and `next` (≥ 13.4) are optional
53
+ peer dependencies, used only by the `@labyrinthe/connect/express` and `@labyrinthe/connect/next` helpers.
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ npm install @labyrinthe/connect
59
+ # or
60
+ pnpm add @labyrinthe/connect
61
+ # or
62
+ yarn add @labyrinthe/connect
63
+ ```
64
+
65
+ Deno: `import { Labyrinthe } from 'npm:@labyrinthe/connect';` — Bun: `bun add @labyrinthe/connect`.
66
+
67
+ ## Quick start
68
+
69
+ Create a key in the client area (*Developers > API keys*). Start with a **test key** (`lsk_test_…`): nothing is sent
70
+ and nothing is billed, but responses have the same shape as in production.
71
+
72
+ ```ts
73
+ // ESM / TypeScript
74
+ import { Labyrinthe } from '@labyrinthe/connect';
75
+
76
+ const client = new Labyrinthe({ apiKey: 'lsk_test_…' }); // or set LABYRINTHE_API_KEY and call new Labyrinthe()
77
+ const message = await client.sms.send({ to: '0812345678', body: 'Your order 1042 is ready.' });
78
+ console.log(message.id, message.status); // "9d5c0a3e-…" "queued"
79
+ ```
80
+
81
+ ```js
82
+ // CommonJS
83
+ const { Labyrinthe } = require('@labyrinthe/connect');
84
+
85
+ async function main() {
86
+ const client = new Labyrinthe(); // reads LABYRINTHE_API_KEY
87
+ const message = await client.sms.send({ to: '0812345678', body: 'Your order 1042 is ready.' });
88
+ console.log(message.status); // "queued"
89
+ }
90
+ main();
91
+ ```
92
+
93
+ | Key | Mode | Effect |
94
+ |---|---|---|
95
+ | `lsk_test_…` | Sandbox | Simulated provider: nothing is sent, no credit is used, the balance is not checked. `otp.send()` returns the code (`testCode`). |
96
+ | `lsk_live_…` | Production | SMS are really sent and credits are used. |
97
+
98
+ **Keep the key server-side**: never in a browser, a mobile app or a `NEXT_PUBLIC_…` variable. The client refuses to
99
+ start in a browser (`InvalidArgumentError`) unless you pass `dangerouslyAllowBrowser: true`. To verify phone numbers
100
+ from a web page, use [`@labyrinthe/otp-widget`](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/tree/main/widget-otp),
101
+ which calls *your* server.
102
+
103
+ Runnable examples: [`examples/send-sms.ts`](examples/send-sms.ts), [`examples/otp-login.ts`](examples/otp-login.ts),
104
+ [`examples/webhook-express.ts`](examples/webhook-express.ts), [`examples/webhook-next-route.ts`](examples/webhook-next-route.ts).
105
+
106
+ ## Configuration
107
+
108
+ ```ts
109
+ import { Labyrinthe } from '@labyrinthe/connect';
110
+
111
+ const client = new Labyrinthe({
112
+ apiKey: process.env.LABYRINTHE_API_KEY, // default
113
+ baseUrl: 'https://api.labyrinthe-rdc.com/api/public/v1', // default (or LABYRINTHE_BASE_URL)
114
+ timeoutMs: 30_000,
115
+ maxRetries: 2,
116
+ maxRetryWaitMs: 10_000,
117
+ defaultCountryCode: '243',
118
+ defaultSender: 'MYSHOP',
119
+ fetch: globalThis.fetch,
120
+ logger: console,
121
+ });
122
+ ```
123
+
124
+ | Option | Default | Description |
125
+ |---|---|---|
126
+ | `apiKey` | `LABYRINTHE_API_KEY` | `lsk_live_…` or `lsk_test_…`. Missing → `InvalidArgumentError`. |
127
+ | `baseUrl` | `LABYRINTHE_BASE_URL`, then `https://api.labyrinthe-rdc.com/api/public/v1` | A bare host (`http://localhost:8000`) is completed with `/api/public/v1`. |
128
+ | `timeoutMs` | `30000` | Timeout of **each attempt**, in milliseconds. |
129
+ | `maxRetries` | `2` | Automatic retries when safe (0 to 10; `0` disables them). |
130
+ | `maxRetryWaitMs` | `10000` | Longest `Retry-After` the SDK waits for; beyond, the error is thrown. Also caps the backoff. |
131
+ | `defaultCountryCode` | `'243'` (DR Congo) | Added to local numbers: `0812345678` → `243812345678`. 1 to 3 digits, no `+`. |
132
+ | `defaultSender` | — | Sender ID used when `sms.send()` / `sendMany()` get no `sender` (otherwise the platform's default). |
133
+ | `fetch` | global `fetch` | Any WHATWG-compatible `fetch` (undici, a proxy agent, a mock). |
134
+ | `logger` | — | `{ debug?, info?, warn?, error? }(message, context)`: `console`, pino, winston… See [logging](#logging-and-secrets). |
135
+ | `dangerouslyAllowBrowser` | `false` | Allows the client in a browser. Your key would be public: don't. |
136
+
137
+ | Environment variable | Read by |
138
+ |---|---|
139
+ | `LABYRINTHE_API_KEY` | `new Labyrinthe()` when `apiKey` is not given |
140
+ | `LABYRINTHE_BASE_URL` | `new Labyrinthe()` when `baseUrl` is not given (local backend, staging) |
141
+ | `LABYRINTHE_WEBHOOK_SECRET` | `labyrintheWebhook()` (Express) and `labyrintheWebhookHandler()` (Next.js) when `secret` is not given |
142
+
143
+ Read-only properties: `client.isTestMode`, `client.isLiveMode`, `client.baseUrl`. `console.log(client)` and
144
+ `JSON.stringify(client)` show `{ apiKey: 'lsk_test_****', baseUrl, testMode }`, never the key.
145
+
146
+ Every method accepts a last, optional argument of per-call options (`RequestOptions`):
147
+
148
+ | Option | Description |
149
+ |---|---|
150
+ | `signal` | `AbortSignal`: cancels the call, including the waits between retries (`RequestAbortedError`). |
151
+ | `timeoutMs` | Timeout of each attempt for this call. |
152
+ | `requestId` | Your own `X-Request-Id` (1 to 128 characters among `A-Z a-z 0-9 . _ : -`), sent on every retry. |
153
+ | `onResponse` | Receives `{ status, requestId, apiVersion, replayed, rateLimit: { limit, remaining }, headers }` of the successful response. |
154
+
155
+ ```ts
156
+ await client.balance.get({ signal: AbortSignal.timeout(5_000) });
157
+ ```
158
+
159
+ ### Phone numbers
160
+
161
+ The SDK normalises numbers exactly like the API and validates them before any call (`InvalidArgumentError`; the
162
+ number is never quoted in the message):
163
+
164
+ | Input | Normalised |
165
+ |---|---|
166
+ | `+243 81 234 5678`, `00243812345678`, `243812345678` | `243812345678` |
167
+ | `0812345678`, `812345678` (local) | `243812345678` (with `defaultCountryCode`) |
168
+ | `+33 6 12 34 56 78`, `33612345678` (no `+`, 11+ digits not starting with 0) | `33612345678` |
169
+
170
+ Numbers are sent to the API in E.164 with `+`, so a short foreign number keeps its country code; for a foreign number
171
+ of 10 digits or less, always type the `+`. Responses return digits without `+`. Helpers:
172
+ `PhoneNumber.normalize(raw, countryCode?)` (`string | null`), `PhoneNumber.isValid()`, `PhoneNumber.normalizeOrThrow()`
173
+ and `PhoneNumber.mask('243812345678')` → `24381*****78`.
174
+
175
+ ## Sending SMS
176
+
177
+ ### One recipient
178
+
179
+ ```ts
180
+ const message = await client.sms.send({
181
+ to: '+243 81 234 5678', // +243…, 00243…, 243…, 0812…, 812… are all accepted
182
+ body: 'Your order 1042 is ready.',
183
+ sender: 'MYSHOP', // optional: an approved Sender ID (see client.senderIds.list())
184
+ reference: 'order-1042', // optional: your own reference (max 64 characters), filterable in sms.list()
185
+ stripAccents: false, // optional: see "Cost before sending"
186
+ idempotencyKey: 'order-1042-ready', // optional: see "Retries and idempotency"
187
+ });
188
+
189
+ message.id; // "9d5c0a3e-…"
190
+ message.recipient; // "243812345678"
191
+ message.status; // "queued"
192
+ message.encoding; // "gsm7" | "ucs2"
193
+ message.segments; // 1
194
+ message.creditsCharged; // 1 (fictitious with a test key)
195
+ message.reference; // "order-1042"
196
+ message.createdAt; // Date
197
+ ```
198
+
199
+ Limits checked locally: `body` 1 to 1600 characters, `sender` 1 to 11 characters, `reference` ≤ 64 characters. A
200
+ `Message` never contains the text (it may hold a code).
201
+
202
+ Sending is asynchronous: `202 Accepted` means *queued and paid for*, not *delivered*. Follow the status with the
203
+ `message.status_changed` webhook (recommended) or by polling `get()`:
204
+
205
+ ```ts
206
+ import { isFailedStatus, isFinalStatus } from '@labyrinthe/connect';
207
+
208
+ const latest = await client.sms.get(message.id); // NotFoundError if unknown
209
+ if (isFinalStatus(latest.status)) {
210
+ console.log(isFailedStatus(latest.status) ? `Failed: ${latest.errorCode}` : 'Delivered');
211
+ }
212
+ ```
213
+
214
+ Statuses (`MessageStatus`): `queued` → `submitted` → `sent` → `delivered`, or `failed`, `rejected`, `expired`,
215
+ `undeliverable` (final). `unknown` means a status added to the API after this SDK version. Helpers:
216
+ `isPendingStatus()`, `isFinalStatus()`, `isFailedStatus()`.
217
+
218
+ ### Several recipients: all or nothing
219
+
220
+ ```ts
221
+ import { RecipientNotSendableError } from '@labyrinthe/connect';
222
+
223
+ try {
224
+ const batch = await client.sms.sendMany({
225
+ recipients: ['0812345678', '0899999999', '+33612345678'],
226
+ body: 'Meeting tomorrow at 9am.',
227
+ reference: 'meeting-2026-11-07',
228
+ });
229
+ batch.messages; // one Message per distinct number (duplicates are merged after normalisation)
230
+ batch.idempotencyKey; // the key used (yours or generated)
231
+ batch.replayed; // true if the API replayed an earlier identical send
232
+ batch.requestId; // X-Request-Id of the response
233
+ } catch (error) {
234
+ if (error instanceof RecipientNotSendableError) {
235
+ // NOTHING was sent and nothing was billed: remove these numbers and send again.
236
+ for (const { recipient, reason } of error.rejected) console.log(recipient, reason); // invalid_phone | blacklisted | opted_out
237
+ } else {
238
+ throw error;
239
+ }
240
+ }
241
+ ```
242
+
243
+ Up to 1000 recipients per call. Every recipient, the Sender ID and the balance are checked **before** anything is
244
+ sent: one refused number (or an insufficient balance) means no SMS at all. Numbers the SDK can already tell are
245
+ invalid are refused locally (`InvalidArgumentError`, with their index in `recipients`).
246
+
247
+ ### Cost before sending
248
+
249
+ 1 credit = 1 SMS segment. A pure ASCII message (GSM-7) holds 160 characters (153 per segment beyond). **Any non-ASCII
250
+ character** (é, à, ç, emoji, curly quotes, long dash…) switches the message to UCS-2: 70 characters (67 per segment
251
+ beyond), often doubling the cost.
252
+
253
+ ```ts
254
+ const estimate = await client.sms.estimate({
255
+ body: 'Réunion demain à 9h',
256
+ recipients: ['0812345678', '0899999999'], // a string or an array
257
+ });
258
+ estimate.encoding; // "ucs2"
259
+ estimate.characters; // 19
260
+ estimate.segmentsPerMessage; // 1
261
+ estimate.recipientsCount; // 2 (normalised, deduplicated, invalid numbers ignored)
262
+ estimate.totalCredits; // 2
263
+ estimate.balanceAfter; // balance after the send (negative if insufficient), null if the account never had credits
264
+ estimate.isAffordable; // balanceAfter !== null && balanceAfter >= 0
265
+
266
+ // stripAccents: accents and typographic characters replaced with ASCII → GSM-7, often half the cost
267
+ const ascii = await client.sms.estimate({ body: 'Réunion demain à 9h', recipients: '0812345678', stripAccents: true });
268
+ ascii.encoding; // "gsm7"
269
+ ```
270
+
271
+ `stripAccents` (default `false`) exists on `send()`, `sendMany()`, `estimate()` and `otp.send()`. The transliteration
272
+ is done by the server: do not use it when the exact text matters. `estimate()` sends nothing.
273
+
274
+ ### Listing messages
275
+
276
+ ```ts
277
+ const page = await client.sms.list({
278
+ status: 'failed', // MessageStatus
279
+ recipient: '0812345678', // any format, normalised
280
+ reference: 'order-1042', // exact match
281
+ from: '2026-11-01', // inclusive: a date, a date-time or a Date
282
+ to: new Date(), // inclusive: a date covers the whole day
283
+ page: 1, // default 1
284
+ perPage: 50, // 1 to 100, default 15
285
+ });
286
+ page.data; // Message[]
287
+ page.total; page.currentPage; page.lastPage; page.perPage;
288
+ page.hasMore; // currentPage < lastPage
289
+ const next = await page.nextPage(); // Page | null
290
+
291
+ // Every message of every page, fetched lazily:
292
+ for await (const message of client.sms.list({ reference: 'order-1042' })) {
293
+ console.log(message.createdAt, message.status);
294
+ }
295
+ ```
296
+
297
+ `list()` returns a `PagePromise`: `await` it for one page, or iterate it with `for await` (a `Page` is also
298
+ async-iterable, from that page on). The list contains only messages sent **with the calling API key**; `get(id)` finds
299
+ any message of the account by its `id`.
300
+
301
+ ## Balance and Sender IDs
302
+
303
+ ```ts
304
+ const { balance, held } = await client.balance.get();
305
+ // balance: available credits; held: credits held by running campaigns
306
+
307
+ const senderIds = await client.senderIds.list(); // approved names only, usable as `sender`
308
+ for (const s of senderIds) console.log(s.value, s.isDefault, s.status); // "MYSHOP" true "approved"
309
+ ```
310
+
311
+ `SenderId`: `id`, `value` (≤ 11 characters), `status`, `purpose`, `rejectionReason`, `isDefault`, `createdAt`. Sender
312
+ IDs are requested and approved in the client area.
313
+
314
+ ## OTP: verify a phone number
315
+
316
+ The code is generated, sent by SMS and checked by Labyrinthe Connect. It is stored hashed: the API never returns it in
317
+ live mode, and the SDK never logs it.
318
+
319
+ ```ts
320
+ import { Labyrinthe } from '@labyrinthe/connect';
321
+
322
+ const client = new Labyrinthe();
323
+
324
+ // 1. Send the code. Keep otpId SERVER-SIDE (session, cache, database), never in the browser.
325
+ const otp = await client.otp.send({ phone: '0812345678' });
326
+ req.session.otpId = otp.otpId;
327
+ otp.expiresAt; // Date
328
+ otp.maxAttempts; // 5
329
+ otp.codeLength; // 6 — show that many boxes (4 to 8, account's OTP settings)
330
+ otp.codeType; // "numeric" | "alphanumeric" (typing is case-insensitive)
331
+ otp.resendAfterSeconds; // 60 — "Resend the code" countdown
332
+ otp.testCode; // the code with a lsk_test_ key, null with a live key
333
+
334
+ // 2. The user types the code received by SMS.
335
+
336
+ // 3. Verify it. A wrong code is NOT an exception.
337
+ const result = await client.otp.verify({ otpId: req.session.otpId, code: req.body.code });
338
+ if (result.verified) {
339
+ delete req.session.otpId;
340
+ // log the user in / mark the number as verified
341
+ } else if (result.reason === 'invalid_code') {
342
+ console.log(`${String(result.attemptsRemaining)} attempt(s) left`); // let the user try again
343
+ } else {
344
+ // 'expired' | 'already_used' | 'max_attempts_reached': ask for a new code
345
+ }
346
+ ```
347
+
348
+ `otp.send()` options:
349
+
350
+ | Option | Description |
351
+ |---|---|
352
+ | `phone` | Required. Any accepted format (see [Phone numbers](#phone-numbers)). |
353
+ | `template` | Must contain `{code}`; optional `{app_name}`, `{minutes}`. ≤ 1600 characters. Default: the account's OTP settings. |
354
+ | `locale` | `'fr'` or `'en'`, language of the standard template. Default: the account's OTP settings. |
355
+ | `webOtpDomain` | Host of your site for the Web OTP API (below). Validated locally, lower-cased. |
356
+ | `stripAccents` | ASCII-only SMS (GSM-7). Default `false`. |
357
+ | `idempotencyKey` | ≤ 255 characters; generated when absent (see [Retries](#retries-and-idempotency)). |
358
+
359
+ `OtpSendResult` also has `idempotencyKey`, `replayed` (a replay returns the same `otpId`, without a new SMS) and
360
+ `requestId`. `testCode` is hidden from `console.log()` and `JSON.stringify()`.
361
+
362
+ `otp.verify({ otpId, code })` returns `{ verified, reason, attemptsRemaining }` and is **free**:
363
+
364
+ | `verified` | `reason` | `attemptsRemaining` | What to do |
365
+ |---|---|---|---|
366
+ | `true` | `null` | `null` | Trust the number. |
367
+ | `false` | `invalid_code` | integer | Wrong code, one attempt consumed: let the user try again. |
368
+ | `false` | `max_attempts_reached` | `0` | The code is blocked: send a new one. |
369
+ | `false` | `expired` | `null` | Send a new code. |
370
+ | `false` | `already_used` | `null` | Each code is single-use: send a new one. |
371
+
372
+ An unknown `otpId` (or one from another account) throws `NotFoundError`. The API ignores spaces and case in the code.
373
+
374
+ Rules and limits:
375
+
376
+ - A new `send()` to the same number invalidates the previous code. After a successful verification, a new send is
377
+ accepted right away.
378
+ - Length (4 to 8), alphabet, validity, attempts, resend delay, Sender ID and template come from the account's OTP
379
+ settings (client area > *OTP verification*).
380
+ - `OtpResendTooSoonError` (429 `OTP_RESEND_TOO_SOON`): a code was sent to this number too recently; show a countdown
381
+ of `retryAfterMs`. `OtpRateLimitedError` (429 `OTP_RATE_LIMITED`): hourly cap reached for this number or the whole
382
+ account (`scope`: `'phone' | 'account'`, `limit`, `retryAfterMs`). Neither is retried by the SDK.
383
+ - `otp/send` is limited to 10 requests per minute per API key (on top of the general 120 per minute).
384
+ - The outcome of each verification is also sent by the `otp.verified` / `otp.failed` webhooks.
385
+
386
+ ### Web OTP (automatic code fill on Android)
387
+
388
+ With `webOtpDomain`, the API ends the SMS with the line `@app.example.com #123456`, the format of the
389
+ [Web OTP API](https://developer.mozilla.org/docs/Web/API/WebOTP_API): Chrome on Android then offers to fill the code in
390
+ your page served from **exactly that host**, over HTTPS (top-level page).
391
+
392
+ ```ts
393
+ await client.otp.send({ phone: '0812345678', webOtpDomain: 'app.example.com' }); // host only: no scheme, port or path
394
+ ```
395
+
396
+ ```js
397
+ // In the browser (the @labyrinthe/otp-widget does it for you)
398
+ const { code } = await navigator.credentials.get({ otp: { transport: ['sms'] } });
399
+ ```
400
+
401
+ ## Webhooks
402
+
403
+ Configure an endpoint in the client area (*Developers > Webhooks*) and copy its secret (there is no API to manage
404
+ webhooks). Events are `POST`ed as JSON `{ id, event, data, timestamp }` and signed:
405
+ `X-Signature: sha256=<hex HMAC-SHA256(secret, raw body)>`. The SDK checks the signature in constant time on the **raw
406
+ body**, before parsing, then the signed `timestamp` (5 minutes by default) to block replays.
407
+
408
+ Answer `2xx` within 10 seconds (queue slow work). Otherwise the delivery is retried up to 5 times (after 30 s, 2 min,
409
+ 5 min, 15 min): **deduplicate on `event.id`**, stable across attempts. Order is not guaranteed.
410
+
411
+ | `event` | `data` |
412
+ |---|---|
413
+ | `message.status_changed` | `messageId`, `recipient`, `status`, `providerMessageId`, `campaignId`, `reference`, `errorCode`, `errorMessage` |
414
+ | `campaign.completed` | `campaignId`, `name`, `recipientsCount`, `messagesByStatus`, `creditsCharged`, `completedAt` |
415
+ | `balance.low` | `balance`, `threshold` |
416
+ | `otp.verified` | `otpId`, `phone`, `status` (`verified`), `reason` (`null`), `verifiedAt` — never the code |
417
+ | `otp.failed` | same fields: `status` `failed` (`reason` `max_attempts_reached`) or `expired` (`reason` `expired`), `verifiedAt` `null` |
418
+ | `test.ping` | `{}` — sent by the *Test* button of the client area |
419
+ | `unknown` | an event added after this SDK version: its name is in `type`, its content in `data` (as sent). Acknowledge it with a 2xx. |
420
+
421
+ Every event also has `id`, `type` (the name as sent), `timestamp` (`Date`) and `raw` (the decoded envelope). Switch
422
+ on `event.event` to get the typed `data`.
423
+
424
+ ### Express
425
+
426
+ ```ts
427
+ import express from 'express';
428
+ import { labyrintheWebhook } from '@labyrinthe/connect/express';
429
+
430
+ const app = express();
431
+
432
+ // BEFORE express.json() (or with express.raw({ type: 'application/json' }) on this route): the raw body is needed.
433
+ app.post('/webhooks/labyrinthe', labyrintheWebhook({
434
+ secret: process.env.LABYRINTHE_WEBHOOK_SECRET, // default
435
+ toleranceSeconds: 300, // default; 0 disables the timestamp check
436
+ onEvent: async (event) => {
437
+ if (!(await markProcessed(event.id))) return; // your own dedup (e.g. a unique insert): already handled
438
+ switch (event.event) {
439
+ case 'message.status_changed':
440
+ await updateMessage(event.data.messageId, event.data.status);
441
+ break;
442
+ case 'otp.failed':
443
+ await flagPhone(event.data.phone, event.data.reason);
444
+ break;
445
+ }
446
+ },
447
+ }));
448
+
449
+ app.use(express.json());
450
+ ```
451
+
452
+ Invalid signature or payload → `400` (`{ error: { code, message } }`). `onEvent` resolves → `200 { received: true }`
453
+ (unless you already answered). `onEvent` throws → `next(error)` (500, the delivery is retried). Without `onEvent`, the
454
+ verified event is set on `req.labyrintheEvent` and `next()` is called. Works with Connect and plain `node:http` too.
455
+
456
+ ### Next.js (App Router)
457
+
458
+ ```ts
459
+ // app/api/webhooks/labyrinthe/route.ts
460
+ import { labyrintheWebhookHandler } from '@labyrinthe/connect/next';
461
+
462
+ export const POST = labyrintheWebhookHandler({
463
+ secret: process.env.LABYRINTHE_WEBHOOK_SECRET, // default
464
+ onEvent: async (event) => {
465
+ if (event.event === 'balance.low') {
466
+ await alertFinance(event.data.balance, event.data.threshold);
467
+ }
468
+ // return nothing → 200 { received: true }; or return your own Response
469
+ },
470
+ });
471
+ ```
472
+
473
+ Works on the Node.js and Edge runtimes, and anywhere a handler takes a Fetch `Request` and returns a `Response`
474
+ (Hono, `Bun.serve`, `Deno.serve`…). Invalid signature → `400`; no secret configured → `500`
475
+ `WEBHOOK_SECRET_MISSING`; `onEvent` throws → `500` `WEBHOOK_HANDLER_FAILED` (logged with the event id; the delivery
476
+ is retried).
477
+
478
+ ### Any runtime (Cloudflare Workers, Deno, Bun…)
479
+
480
+ ```ts
481
+ import { InvalidWebhookPayloadError, SignatureVerificationError, Webhooks } from '@labyrinthe/connect';
482
+
483
+ export default {
484
+ async fetch(request: Request, env: { LABYRINTHE_WEBHOOK_SECRET: string }): Promise<Response> {
485
+ try {
486
+ const event = await Webhooks.constructEvent(
487
+ await request.arrayBuffer(), // RAW body: string, Uint8Array/Buffer or ArrayBuffer
488
+ request.headers.get('x-signature'),
489
+ env.LABYRINTHE_WEBHOOK_SECRET,
490
+ { toleranceSeconds: 300 },
491
+ );
492
+ // … handle event (deduplicate on event.id)
493
+ return new Response('ok');
494
+ } catch (error) {
495
+ if (error instanceof SignatureVerificationError || error instanceof InvalidWebhookPayloadError) {
496
+ return new Response('invalid', { status: 400 });
497
+ }
498
+ throw error;
499
+ }
500
+ },
501
+ };
502
+ ```
503
+
504
+ `Webhooks.constructEvent()` is asynchronous (Web Crypto) and never accepts a parsed object. It throws
505
+ `SignatureVerificationError` (missing or wrong signature, timestamp out of tolerance) or `InvalidWebhookPayloadError`
506
+ (signed body that is not an envelope): answer `400`. `Webhooks.verifySignature(body, header, secret)` returns a
507
+ boolean; `Webhooks.sign(body, secret)` builds a valid `X-Signature` value to test your endpoint.
508
+ `Webhooks.SIGNATURE_HEADER` is `'X-Signature'`.
509
+
510
+ ## Errors
511
+
512
+ Every error extends `LabyrintheError` and has `code` (stable: switch on it), `message`, `status` (HTTP status, or
513
+ `null` without a response) and `details` (always an object). API errors extend `ApiError` and also expose `headers`,
514
+ `requestId` and `apiVersion`. API messages are in French, for humans; the list of codes is open, so always keep a
515
+ generic branch.
516
+
517
+ | Class | HTTP | `code` | What to do |
518
+ |---|---|---|---|
519
+ | `AuthenticationError` | 401 | `UNAUTHENTICATED` | Missing, invalid or revoked key: check `LABYRINTHE_API_KEY`. |
520
+ | `PermissionError` | 403 | `FORBIDDEN`, `OTP_DISABLED_FOR_ACCOUNT` | IP not allowed for this key; OTP disabled for the account. |
521
+ | `AccountSuspendedError` | 403 | `ACCOUNT_SUSPENDED` | Whole API closed: contact the support with `requestId`. Extends `PermissionError`. |
522
+ | `NotFoundError` | 404 | `NOT_FOUND` | Unknown message id or `otpId` (or one of another account). |
523
+ | `ValidationError` | 422 | `VALIDATION_ERROR` | Fix the fields in `fieldErrors` (`{ body: ['…'] }`). |
524
+ | `RecipientNotSendableError` | 422 | `RECIPIENT_NOT_SENDABLE` | Nothing was sent: remove `rejected` (`reason`: `invalid_phone`, `blacklisted`, `opted_out`). |
525
+ | `SenderIdNotApprovedError` | 422 | `SENDER_ID_NOT_APPROVED` | Use a name from `senderIds.list()`. |
526
+ | `InvalidPhoneError` | 422 | `INVALID_PHONE` | OTP number unusable: ask the user for another one. |
527
+ | `ApiError` | 422 | `INVALID_OTP_TEMPLATE` | Your `template` is invalid (must contain `{code}`). |
528
+ | `InsufficientCreditsError` | 409 | `INSUFFICIENT_CREDITS` | Nothing was sent: buy credits (`available`, `required`). Never in test mode. |
529
+ | `IdempotencyKeyReusedError` | 409 | `IDEMPOTENCY_KEY_REUSED` | Same key, different content: use a new key for a new message. |
530
+ | `IdempotencyRequestInProgressError` | 409 | `IDEMPOTENCY_REQUEST_IN_PROGRESS` | Already retried by the SDK; wait `retryAfterMs`, call again with the **same** key. |
531
+ | `IdempotencyKeyUnresolvedError` | 409 | `IDEMPOTENCY_KEY_UNRESOLVED` | Outcome unknown, an SMS may have been sent: check, then use a **new** key ([below](#retries-and-idempotency)). |
532
+ | `RateLimitError` | 429 | `TOO_MANY_REQUESTS` | Already retried when safe; wait `retryAfterMs`. |
533
+ | `OtpResendTooSoonError` | 429 | `OTP_RESEND_TOO_SOON` | Show a countdown of `retryAfterMs`. Extends `RateLimitError`. |
534
+ | `OtpRateLimitedError` | 429 | `OTP_RATE_LIMITED` | Hourly cap (`scope`, `limit`, `retryAfterMs`). Extends `RateLimitError`. |
535
+ | `FeatureDisabledError` | 503 | `FEATURE_DISABLED`, `OTP_SERVICE_DISABLED` | Maintenance: nothing was done ([below](#maintenance-503-feature_disabled)). Extends `ServiceUnavailableError`. |
536
+ | `ServiceUnavailableError` | 5xx | any | Already retried when safe; try again later. |
537
+ | `ApiError` | other | `HTTP_ERROR` (405), unknown codes | Log `code`, `status`, `requestId`. |
538
+ | `ConnectionError` | — | `CONNECTION_ERROR` | API unreachable (DNS, TLS…). Sends were already retried; calling again with the same `idempotencyKey` is safe. |
539
+ | `TimeoutError` | — | `TIMEOUT` | An attempt exceeded `timeoutMs`. Extends `ConnectionError`. |
540
+ | `RequestAbortedError` | — | `REQUEST_ABORTED` | Cancelled through `signal`. Never retried. |
541
+ | `InvalidArgumentError` | — | `INVALID_ARGUMENT` | Invalid input detected before any call (number, length, option). |
542
+ | `UnexpectedResponseError` | 2xx | `UNEXPECTED_RESPONSE` | A response the SDK cannot read (proxy page…). |
543
+ | `SignatureVerificationError` | — | `SIGNATURE_VERIFICATION_FAILED` | Webhook signature or timestamp invalid: answer 400. |
544
+ | `InvalidWebhookPayloadError` | — | `INVALID_WEBHOOK_PAYLOAD` | Signed webhook body that is not an envelope: answer 400. |
545
+
546
+ ```ts
547
+ import { ApiError, InsufficientCreditsError, LabyrintheError, ValidationError } from '@labyrinthe/connect';
548
+
549
+ try {
550
+ await client.sms.send({ to: '0812345678', body: 'Hello' });
551
+ } catch (error) {
552
+ if (error instanceof InsufficientCreditsError) {
553
+ await alertFinance(error.available, error.required);
554
+ } else if (error instanceof ValidationError) {
555
+ console.error(error.fieldErrors);
556
+ } else if (error instanceof ApiError) {
557
+ console.error(error.code, error.status, error.requestId); // give requestId to the support
558
+ } else if (error instanceof LabyrintheError) {
559
+ console.error(error.code); // CONNECTION_ERROR, TIMEOUT, INVALID_ARGUMENT…
560
+ } else {
561
+ throw error;
562
+ }
563
+ }
564
+ ```
565
+
566
+ ### Maintenance (503 `FEATURE_DISABLED`)
567
+
568
+ A platform administrator can temporarily shut down the whole API (`blockedBy === 'api'`: every call, refused even
569
+ before the key is checked) or one function (`'api.sms_send'`, `'api.otp_send'`, `'api.otp_verify'`). The call throws
570
+ `FeatureDisabledError` and **nothing was done**: no SMS, no credit used. The whole OTP service off throws the same
571
+ class with the historical code `OTP_SERVICE_DISABLED` (`feature === blockedBy === 'otp'`; verifying codes already sent
572
+ keeps working).
573
+
574
+ | Property | Type | Description |
575
+ |---|---|---|
576
+ | `feature` | `string \| null` | Function requested by the call (`api`, `api.sms_send`, `api.otp_send`, `api.otp_verify`, `otp`… open list). |
577
+ | `blockedBy` | `string \| null` | Function actually shut down: `feature` itself or a parent (`api`). |
578
+ | `adminMessage` | `string \| null` | The administrator's message (`details.message`); `error.message` repeats it or is generic. |
579
+ | `until` | `Date \| null` | Scheduled automatic re-enable; `null` when unknown. |
580
+ | `retryAfter` / `retryAfterMs` | `number \| null` | Time left until then (`Retry-After` header), in seconds / milliseconds. |
581
+
582
+ The SDK never retries it with backoff: only when `Retry-After` is present and no longer than `maxRetryWaitMs`, after
583
+ exactly that delay (for every call, `otp.verify()` included, since the request was refused before processing).
584
+ Otherwise treat it as a temporary failure:
585
+
586
+ ```ts
587
+ import { FeatureDisabledError } from '@labyrinthe/connect';
588
+
589
+ try {
590
+ await client.sms.send({ to, body, idempotencyKey: `order-${orderId}-ready` });
591
+ } catch (error) {
592
+ if (error instanceof FeatureDisabledError) {
593
+ // Same idempotency key later: nothing can be sent twice.
594
+ await queue.retryLater({ orderId }, { at: error.until ?? new Date(Date.now() + 15 * 60_000) });
595
+ logger.warn('Labyrinthe maintenance', {
596
+ feature: error.feature, blockedBy: error.blockedBy, message: error.adminMessage, requestId: error.requestId,
597
+ });
598
+ } else {
599
+ throw error;
600
+ }
601
+ }
602
+ ```
603
+
604
+ ## Retries and idempotency
605
+
606
+ The SDK retries automatically (default 2 retries, exponential backoff 0.5 s → 1 s → 2 s… with jitter, capped by
607
+ `maxRetryWaitMs`) **only when a replay cannot cause a side effect twice**:
608
+
609
+ | Call | Network error / timeout | 5xx | 429 `TOO_MANY_REQUESTS` | 409 `IDEMPOTENCY_REQUEST_IN_PROGRESS` |
610
+ |---|---|---|---|---|
611
+ | `sms.get`, `sms.list`, `sms.estimate`, `balance.get`, `senderIds.list` | retried | retried | after `Retry-After` | — |
612
+ | `sms.send`, `sms.sendMany`, `otp.send` (always with an `Idempotency-Key`) | retried, same key | retried, same key | after `Retry-After` | after `Retry-After`, same key |
613
+ | `otp.verify` | **never** (an attempt may be consumed) | **never** | after `Retry-After` | — |
614
+
615
+ - `503 FEATURE_DISABLED` / `OTP_SERVICE_DISABLED`: for every call, only after a `Retry-After` ≤ `maxRetryWaitMs`, never
616
+ with backoff (see [Maintenance](#maintenance-503-feature_disabled)).
617
+ - Never retried: other 4xx, `OTP_RESEND_TOO_SOON`, `OTP_RATE_LIMITED`, `ACCOUNT_SUSPENDED`,
618
+ `IDEMPOTENCY_KEY_UNRESOLVED`. When the API asks to wait longer than `maxRetryWaitMs`, the error is thrown at once.
619
+
620
+ **Idempotency.** Every SMS and OTP send carries an `Idempotency-Key`. Without `idempotencyKey`, the SDK generates a
621
+ UUID (`crypto.randomUUID()`) once per call and reuses it for its own retries, so a network error never produces a
622
+ second SMS. To also protect **your** retries (queue jobs, double clicks, crashes), pass a key derived from your business
623
+ event. A replay within 24 hours returns the original response without sending or billing again (`replayed: true`):
624
+
625
+ ```ts
626
+ const batch = await client.sms.sendMany({
627
+ recipients: ['0812345678'], body: 'Your order 1042 is ready.', idempotencyKey: 'order-1042-ready',
628
+ });
629
+ batch.replayed; // true when this was a replay
630
+
631
+ const otp = await client.otp.send({ phone: '0812345678', idempotencyKey: `login-${loginAttemptId}` });
632
+ otp.replayed; // true: same otpId (and same testCode in test mode), no new SMS
633
+ ```
634
+
635
+ Keys are scoped to your API key and to the operation (`/sms` and `/otp/send` have separate key spaces), at most 255
636
+ characters, and bound to the **normalised** content: the same key with other content throws
637
+ `IdempotencyKeyReusedError`. A request that ended in error does not record the key.
638
+
639
+ **Outcome unknown.** `IdempotencyKeyUnresolvedError` (409 `IDEMPOTENCY_KEY_UNRESOLVED`) means the original request with
640
+ this key failed unexpectedly on the API side: an SMS may already have been sent. The SDK never retries it; do not retry
641
+ automatically either. Check first, then send again with a **new** key (the same key keeps returning this error):
642
+
643
+ ```ts
644
+ import { IdempotencyKeyUnresolvedError } from '@labyrinthe/connect';
645
+
646
+ try {
647
+ await client.sms.send({ to, body, reference, idempotencyKey });
648
+ } catch (error) {
649
+ if (error instanceof IdempotencyKeyUnresolvedError) {
650
+ const { data } = await client.sms.list({ reference });
651
+ if (data.length === 0) await client.sms.send({ to, body, reference, idempotencyKey: crypto.randomUUID() });
652
+ } else {
653
+ throw error;
654
+ }
655
+ }
656
+ ```
657
+
658
+ For an OTP, ask the user whether the code arrived before sending a new one.
659
+
660
+ **Rate limits.** 120 requests per minute per API key, 10 per minute per key on `otp/send`, 120 per minute per IP.
661
+ `onResponse` exposes `rateLimit: { limit, remaining }` (`X-RateLimit-*` headers).
662
+
663
+ ## Test mode, logging, request ids and types
664
+
665
+ ### Test mode
666
+
667
+ With a `lsk_test_…` key the provider is simulated: nothing is sent, no credit is used, statuses and responses have
668
+ the production shape. Two differences: the balance is not checked (`INSUFFICIENT_CREDITS` never happens in test mode)
669
+ and `otp.send()` returns the code in `testCode`, so the whole OTP flow can be tested. `client.isTestMode` tells you
670
+ which mode you are in. Use test keys in development, CI and examples.
671
+
672
+ ### Logging and secrets
673
+
674
+ Pass `logger: console` (or pino, winston…) to receive `debug` (each request and response: method, path, status,
675
+ duration, `replayed`, `requestId`), `warn` (retries) and `info` / `error` (API and network errors) entries. Logs never
676
+ contain the API key, OTP codes, request or response bodies, nor full phone numbers (`recipient` / `phone` query
677
+ parameters are masked). Network error messages never quote the URL query. `console.log()` / `JSON.stringify()` of the
678
+ client, its resources and `OtpSendResult` redact the key and `testCode`.
679
+
680
+ ### Request ids
681
+
682
+ Every API response carries `X-Request-Id`: `error.requestId` on errors, `requestId` on `sendMany()` batches and
683
+ `otp.send()` results, and `onResponse` for any call. Give it to the support. You can send your own (reused on every
684
+ retry of the call):
685
+
686
+ ```ts
687
+ await client.sms.send({ to: '0812345678', body: 'Hello' }, {
688
+ requestId: 'order-1042',
689
+ onResponse: ({ requestId, apiVersion, rateLimit, replayed }) => logger.debug({ requestId, apiVersion, rateLimit, replayed }),
690
+ });
691
+ ```
692
+
693
+ The `User-Agent` is `labyrinthe-node/<version> <runtime>/<version>`; `VERSION` is exported.
694
+
695
+ ### TypeScript types
696
+
697
+ Everything is exported from `@labyrinthe/connect`:
698
+
699
+ - Objects: `Message`, `MessageBatch`, `Estimate`, `Balance`, `SenderId`, `RejectedRecipient`, `OtpSendResult`,
700
+ `OtpVerifyResult` (discriminated on `verified`), `Page`, `PagePromise`, `ResponseInfo`, `RequestOptions`,
701
+ `LabyrintheOptions`.
702
+ - Parameters: `SendSmsParams`, `SendManySmsParams`, `EstimateSmsParams`, `ListSmsParams`, `SendOtpParams`,
703
+ `VerifyOtpParams`, `OtpLocale`.
704
+ - Enums (constant object + union type of the same name): `MessageStatus`, `Encoding`, `SenderIdStatus`,
705
+ `RejectionReason`, `OtpFailureReason`, `OtpCodeType`, `OtpVerificationStatus`, `OtpRateLimitScope`,
706
+ `WebhookEventType`. Each one except `Encoding` and `WebhookEventType` has an `unknown` member for values added to
707
+ the API later.
708
+ - Webhooks: `WebhookEvent` (union), `MessageStatusChangedEvent`, `CampaignCompletedEvent`, `BalanceLowEvent`,
709
+ `OtpVerifiedEvent`, `OtpFailedEvent`, `OtpWebhookData`, `TestPingEvent`, `UnknownEvent`, `RawBody`,
710
+ `ConstructEventOptions`.
711
+ - Errors: every class above, `ApiErrorCode`, `SdkErrorCode`, `ErrorCode`, `ErrorDetails`.
712
+ - Transport: `FetchLike`, `FetchInit`, `FetchResponse`, `Logger`.
713
+
714
+ ```ts
715
+ import { MessageStatus, type Message } from '@labyrinthe/connect';
716
+
717
+ const failed = (m: Message) => m.status === MessageStatus.Failed;
718
+ ```
719
+
720
+ ## Without the package: raw HTTP
721
+
722
+ The API is plain JSON over HTTPS; the SDK only adds validation, retries, typing and webhook helpers. With `fetch`
723
+ (Node 18+):
724
+
725
+ ```ts
726
+ const API = 'https://api.labyrinthe-rdc.com/api/public/v1';
727
+ const headers = {
728
+ Authorization: `Bearer ${process.env.LABYRINTHE_API_KEY}`, // lsk_test_… while testing
729
+ 'Content-Type': 'application/json',
730
+ Accept: 'application/json',
731
+ };
732
+
733
+ // Send an SMS (202). Idempotency-Key: replay safely with the SAME key for 24 h.
734
+ const res = await fetch(`${API}/sms`, {
735
+ method: 'POST',
736
+ headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() },
737
+ body: JSON.stringify({ recipient: '+243812345678', body: 'Your order 1042 is ready.', reference: 'order-1042' }),
738
+ });
739
+ const json = await res.json();
740
+ if (!res.ok) {
741
+ // { error: { code, message, details } }
742
+ throw new Error(`${json.error.code} (HTTP ${res.status}, request ${res.headers.get('x-request-id')})`);
743
+ }
744
+ const [message] = json.data; // [{ id, recipient, status: "queued", segments, credits_charged, … }]
745
+
746
+ // OTP: send (201), then verify (200)
747
+ const sent = await (await fetch(`${API}/otp/send`, {
748
+ method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() },
749
+ body: JSON.stringify({ phone: '+243812345678', locale: 'fr' }),
750
+ })).json(); // { data: { otp_id, expires_at, max_attempts, code_length, code_type, resend_after_seconds, test_code? } }
751
+
752
+ const checked = await (await fetch(`${API}/otp/verify`, {
753
+ method: 'POST', headers, body: JSON.stringify({ otp_id: sent.data.otp_id, code: '123456' }),
754
+ })).json(); // { data: { verified, reason, attempts_remaining } }
755
+ ```
756
+
757
+ Webhook signature with `node:crypto`, on the raw body:
758
+
759
+ ```ts
760
+ import { createHmac, timingSafeEqual } from 'node:crypto';
761
+
762
+ function verifyWebhook(rawBody: Buffer, signatureHeader: string | undefined, secret: string): boolean {
763
+ const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'));
764
+ const given = Buffer.from(signatureHeader ?? '');
765
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) return false;
766
+ const { timestamp } = JSON.parse(rawBody.toString('utf8'));
767
+ return Math.abs(Date.now() / 1000 - timestamp) <= 300; // replay protection
768
+ }
769
+ ```
770
+
771
+ Reference: the OpenAPI 3.1 specification
772
+ [`openapi/openapi.yaml`](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/blob/main/openapi/openapi.yaml)
773
+ (also served by the API at `https://api.labyrinthe-rdc.com/api/public/openapi.yaml`) and the
774
+ [Postman collection](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/tree/main/postman).
775
+
776
+ ## Upgrading
777
+
778
+ - **1.1.x → 1.2.0** (API 1.3.0): no code change. New `FeatureDisabledError` for 503 `FEATURE_DISABLED`; 503
779
+ `OTP_SERVICE_DISABLED` is now thrown as `FeatureDisabledError` too. Both remain `instanceof ServiceUnavailableError`,
780
+ so existing `catch` branches keep working. They are no longer retried with backoff, only after a short `Retry-After`.
781
+ - **1.1.0 → 1.1.1**: numbers are sent to the API in E.164 with `+` (short foreign numbers keep their country code).
782
+ `IdempotencyKeyUnresolvedError` added. No code change.
783
+ - **1.0.x → 1.1.0** (API 1.2.0): `OtpVerifyResult` gains `attemptsRemaining` (update deep-equality checks on the whole
784
+ result); `otp.send()` is idempotent and retried safely; numbers without `+` of 11 digits or more not starting with 0
785
+ are kept as international (`33612345678`).
786
+
787
+ Details: [CHANGELOG](CHANGELOG.md). Within v1, the API only adds compatible changes (fields, codes, events): the SDK
788
+ ignores what it does not know.
789
+
790
+ ## Contributing
791
+
792
+ Issues and pull requests go to the monorepo
793
+ [Labyrinthe-Team/labyrinthe-connect-sdks](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks)
794
+ (see [CONTRIBUTING.md](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/blob/main/CONTRIBUTING.md)). In
795
+ `node/`:
796
+
797
+ ```bash
798
+ npm ci
799
+ npm run check # lint + typecheck + tests (mocked fetch, OpenAPI contract) + build + check:dist + publint + attw
800
+
801
+ # integration tests against a local backend, with a TEST key only
802
+ LABYRINTHE_API_KEY=lsk_test_… LABYRINTHE_BASE_URL=http://localhost:8000 npm run test:integration
803
+ ```
804
+
805
+ Security issues: email connect@labyrinthe-rdc.com rather than opening a public issue.
806
+
807
+ ## License
808
+
809
+ MIT — see [LICENSE](LICENSE).