@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.fr.md ADDED
@@ -0,0 +1,819 @@
1
+ # Labyrinthe Connect — SDK Node.js / TypeScript
2
+
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
+ [![Licence : MIT](https://img.shields.io/badge/licence-MIT-blue.svg)](LICENSE)
6
+ ![Node](https://img.shields.io/badge/node-%E2%89%A5%2018-339933)
7
+
8
+ Client JavaScript / TypeScript officiel de [Labyrinthe Connect](https://labyrinthe-rdc.com), la plateforme SMS et OTP
9
+ pour la RD Congo et l'Afrique centrale : envoyer des SMS, suivre leur remise et vérifier des numéros de téléphone par
10
+ code à usage unique (OTP).
11
+
12
+ *English version: [README.md](README.md).*
13
+
14
+ - **Entièrement typé** : objets en camelCase, `Date`, enums en littéraux de chaîne, une classe d'erreur par code.
15
+ - **Jamais de double SMS** : chaque envoi porte une `Idempotency-Key` ; les relances automatiques n'ont lieu que
16
+ lorsqu'un rejeu est sans risque.
17
+ - **Aucune dépendance**, ESM + CommonJS, Node.js 18+, Vercel / Next.js Edge, Cloudflare Workers, Deno et Bun.
18
+ - **Sûr par défaut** : la clé API, les codes OTP et les numéros complets n'apparaissent jamais dans les logs,
19
+ `console.log()` ou `JSON.stringify()` ; les webhooks sont vérifiés en temps constant sur le corps brut.
20
+ - Helpers de webhook pour **Express** et **Next.js** (App Router, et tout runtime Fetch `Request` / `Response`).
21
+
22
+ Version **1.2.0**, construite sur la spécification de l'API **1.3.0**
23
+ ([`openapi.yaml`](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/blob/main/openapi/openapi.yaml)).
24
+ Nouveautés : [CHANGELOG](CHANGELOG.md) (en anglais).
25
+
26
+ ## Sommaire
27
+
28
+ 1. [Prérequis](#prérequis)
29
+ 2. [Installation](#installation)
30
+ 3. [Démarrage rapide](#démarrage-rapide)
31
+ 4. [Configuration](#configuration)
32
+ 5. [Envoyer des SMS](#envoyer-des-sms)
33
+ 6. [Solde et Sender IDs](#solde-et-sender-ids)
34
+ 7. [Vérifier un numéro par OTP](#vérifier-un-numéro-par-otp)
35
+ 8. [Webhooks](#webhooks)
36
+ 9. [Erreurs](#erreurs)
37
+ 10. [Relances et idempotence](#relances-et-idempotence)
38
+ 11. [Mode test, journalisation, identifiants de requête et types](#mode-test-journalisation-identifiants-de-requête-et-types)
39
+ 12. [Sans le package (HTTP brut)](#sans-le-package-http-brut)
40
+ 13. [Mise à jour](#mise-à-jour)
41
+ 14. [Contribuer](#contribuer) · [Licence](#licence)
42
+
43
+ ## Prérequis
44
+
45
+ | Runtime | Prise en charge |
46
+ |---|---|
47
+ | Node.js 18, 20, 22, 24 (`engines: >=18`) | ✅ `fetch` global ; sur Node 18, Web Crypto est chargé depuis le module crypto au premier usage |
48
+ | Vercel Edge, routes Edge de Next.js | ✅ |
49
+ | Cloudflare Workers | ✅ sans le drapeau `nodejs_compat` |
50
+ | Deno, Bun | ✅ (`npm:@labyrinthe/connect` sous Deno) |
51
+ | Navigateurs | ❌ refusé par défaut : la clé API doit rester côté serveur |
52
+
53
+ Les types sont fournis pour ESM (`.d.ts`) et CommonJS (`.d.cts`). `express` (≥ 4.17) et `next` (≥ 13.4) sont des
54
+ dépendances pairs facultatives, utilisées seulement par les helpers `@labyrinthe/connect/express` et
55
+ `@labyrinthe/connect/next`.
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ npm install @labyrinthe/connect
61
+ # ou
62
+ pnpm add @labyrinthe/connect
63
+ # ou
64
+ yarn add @labyrinthe/connect
65
+ ```
66
+
67
+ Deno : `import { Labyrinthe } from 'npm:@labyrinthe/connect';` — Bun : `bun add @labyrinthe/connect`.
68
+
69
+ ## Démarrage rapide
70
+
71
+ Créez une clé dans l'espace client (*Développeurs > Clés API*). Commencez avec une **clé de test** (`lsk_test_…`) :
72
+ rien n'est envoyé ni facturé, mais les réponses ont la même forme qu'en production.
73
+
74
+ ```ts
75
+ // ESM / TypeScript
76
+ import { Labyrinthe } from '@labyrinthe/connect';
77
+
78
+ const client = new Labyrinthe({ apiKey: 'lsk_test_…' }); // ou LABYRINTHE_API_KEY, puis new Labyrinthe()
79
+ const message = await client.sms.send({ to: '0812345678', body: 'Votre commande 1042 est prête.' });
80
+ console.log(message.id, message.status); // "9d5c0a3e-…" "queued"
81
+ ```
82
+
83
+ ```js
84
+ // CommonJS
85
+ const { Labyrinthe } = require('@labyrinthe/connect');
86
+
87
+ async function main() {
88
+ const client = new Labyrinthe(); // lit LABYRINTHE_API_KEY
89
+ const message = await client.sms.send({ to: '0812345678', body: 'Votre commande 1042 est prête.' });
90
+ console.log(message.status); // "queued"
91
+ }
92
+ main();
93
+ ```
94
+
95
+ | Clé | Mode | Effet |
96
+ |---|---|---|
97
+ | `lsk_test_…` | Bac à sable | Fournisseur simulé : rien n'est envoyé, aucun crédit consommé, solde non vérifié. `otp.send()` renvoie le code (`testCode`). |
98
+ | `lsk_live_…` | Production | Les SMS partent réellement et consomment des crédits. |
99
+
100
+ **Gardez la clé côté serveur** : jamais dans un navigateur, une application mobile ou une variable `NEXT_PUBLIC_…`.
101
+ Le client refuse de démarrer dans un navigateur (`InvalidArgumentError`), sauf avec `dangerouslyAllowBrowser: true`.
102
+ Pour vérifier un numéro depuis une page web, utilisez
103
+ [`@labyrinthe/otp-widget`](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/tree/main/widget-otp), qui appelle
104
+ *votre* serveur.
105
+
106
+ Exemples exécutables : [`examples/send-sms.ts`](examples/send-sms.ts), [`examples/otp-login.ts`](examples/otp-login.ts),
107
+ [`examples/webhook-express.ts`](examples/webhook-express.ts), [`examples/webhook-next-route.ts`](examples/webhook-next-route.ts).
108
+
109
+ ## Configuration
110
+
111
+ ```ts
112
+ import { Labyrinthe } from '@labyrinthe/connect';
113
+
114
+ const client = new Labyrinthe({
115
+ apiKey: process.env.LABYRINTHE_API_KEY, // par défaut
116
+ baseUrl: 'https://api.labyrinthe-rdc.com/api/public/v1', // par défaut (ou LABYRINTHE_BASE_URL)
117
+ timeoutMs: 30_000,
118
+ maxRetries: 2,
119
+ maxRetryWaitMs: 10_000,
120
+ defaultCountryCode: '243',
121
+ defaultSender: 'MYSHOP',
122
+ fetch: globalThis.fetch,
123
+ logger: console,
124
+ });
125
+ ```
126
+
127
+ | Option | Défaut | Description |
128
+ |---|---|---|
129
+ | `apiKey` | `LABYRINTHE_API_KEY` | `lsk_live_…` ou `lsk_test_…`. Absente → `InvalidArgumentError`. |
130
+ | `baseUrl` | `LABYRINTHE_BASE_URL`, puis `https://api.labyrinthe-rdc.com/api/public/v1` | Un hôte seul (`http://localhost:8000`) est complété par `/api/public/v1`. |
131
+ | `timeoutMs` | `30000` | Délai maximal de **chaque tentative**, en millisecondes. |
132
+ | `maxRetries` | `2` | Relances automatiques quand elles sont sans risque (0 à 10 ; `0` les désactive). |
133
+ | `maxRetryWaitMs` | `10000` | Plus long `Retry-After` accepté ; au-delà, l'erreur est levée. Plafonne aussi le backoff. |
134
+ | `defaultCountryCode` | `'243'` (RD Congo) | Ajouté aux numéros locaux : `0812345678` → `243812345678`. 1 à 3 chiffres, sans `+`. |
135
+ | `defaultSender` | — | Sender ID utilisé quand `sms.send()` / `sendMany()` n'en reçoivent pas (sinon celui de la plateforme). |
136
+ | `fetch` | `fetch` global | Tout `fetch` compatible WHATWG (undici, agent proxy, mock). |
137
+ | `logger` | — | `{ debug?, info?, warn?, error? }(message, context)` : `console`, pino, winston… Voir [journalisation](#journalisation-et-secrets). |
138
+ | `dangerouslyAllowBrowser` | `false` | Autorise le client dans un navigateur. Votre clé deviendrait publique : à éviter. |
139
+
140
+ | Variable d'environnement | Lue par |
141
+ |---|---|
142
+ | `LABYRINTHE_API_KEY` | `new Labyrinthe()` sans `apiKey` |
143
+ | `LABYRINTHE_BASE_URL` | `new Labyrinthe()` sans `baseUrl` (backend local, préproduction) |
144
+ | `LABYRINTHE_WEBHOOK_SECRET` | `labyrintheWebhook()` (Express) et `labyrintheWebhookHandler()` (Next.js) sans `secret` |
145
+
146
+ Propriétés en lecture seule : `client.isTestMode`, `client.isLiveMode`, `client.baseUrl`. `console.log(client)` et
147
+ `JSON.stringify(client)` affichent `{ apiKey: 'lsk_test_****', baseUrl, testMode }`, jamais la clé.
148
+
149
+ Chaque méthode accepte un dernier argument facultatif d'options par appel (`RequestOptions`) :
150
+
151
+ | Option | Description |
152
+ |---|---|
153
+ | `signal` | `AbortSignal` : annule l'appel, attentes entre relances comprises (`RequestAbortedError`). |
154
+ | `timeoutMs` | Délai maximal de chaque tentative pour cet appel. |
155
+ | `requestId` | Votre propre `X-Request-Id` (1 à 128 caractères parmi `A-Z a-z 0-9 . _ : -`), envoyé à chaque relance. |
156
+ | `onResponse` | Reçoit `{ status, requestId, apiVersion, replayed, rateLimit: { limit, remaining }, headers }` de la réponse réussie. |
157
+
158
+ ```ts
159
+ await client.balance.get({ signal: AbortSignal.timeout(5_000) });
160
+ ```
161
+
162
+ ### Numéros de téléphone
163
+
164
+ Le SDK normalise les numéros exactement comme l'API et les valide avant tout appel (`InvalidArgumentError` ; le
165
+ numéro n'est jamais cité dans le message) :
166
+
167
+ | Saisie | Normalisé |
168
+ |---|---|
169
+ | `+243 81 234 5678`, `00243812345678`, `243812345678` | `243812345678` |
170
+ | `0812345678`, `812345678` (local) | `243812345678` (avec `defaultCountryCode`) |
171
+ | `+33 6 12 34 56 78`, `33612345678` (sans `+`, 11 chiffres ou plus ne commençant pas par 0) | `33612345678` |
172
+
173
+ Les numéros partent vers l'API en E.164 avec `+`, donc un numéro étranger court garde son indicatif ; pour un numéro
174
+ étranger de 10 chiffres ou moins, saisissez toujours le `+`. Les réponses renvoient les chiffres sans `+`. Helpers :
175
+ `PhoneNumber.normalize(raw, countryCode?)` (`string | null`), `PhoneNumber.isValid()`, `PhoneNumber.normalizeOrThrow()`
176
+ et `PhoneNumber.mask('243812345678')` → `24381*****78`.
177
+
178
+ ## Envoyer des SMS
179
+
180
+ ### Un destinataire
181
+
182
+ ```ts
183
+ const message = await client.sms.send({
184
+ to: '+243 81 234 5678', // +243…, 00243…, 243…, 0812…, 812… sont acceptés
185
+ body: 'Votre commande 1042 est prête.',
186
+ sender: 'MYSHOP', // facultatif : un Sender ID approuvé (voir client.senderIds.list())
187
+ reference: 'order-1042', // facultatif : votre référence (64 caractères max.), filtrable dans sms.list()
188
+ stripAccents: false, // facultatif : voir « Coût avant envoi »
189
+ idempotencyKey: 'order-1042-ready', // facultatif : voir « Relances et idempotence »
190
+ });
191
+
192
+ message.id; // "9d5c0a3e-…"
193
+ message.recipient; // "243812345678"
194
+ message.status; // "queued"
195
+ message.encoding; // "gsm7" | "ucs2"
196
+ message.segments; // 1
197
+ message.creditsCharged; // 1 (fictif avec une clé de test)
198
+ message.reference; // "order-1042"
199
+ message.createdAt; // Date
200
+ ```
201
+
202
+ Limites vérifiées localement : `body` de 1 à 1600 caractères, `sender` de 1 à 11 caractères, `reference` ≤ 64
203
+ caractères. Un `Message` ne contient jamais le texte (il peut contenir un code).
204
+
205
+ L'envoi est asynchrone : `202 Accepted` signifie *mis en file et payé*, pas *remis*. Suivez le statut avec le webhook
206
+ `message.status_changed` (recommandé) ou en interrogeant `get()` :
207
+
208
+ ```ts
209
+ import { isFailedStatus, isFinalStatus } from '@labyrinthe/connect';
210
+
211
+ const latest = await client.sms.get(message.id); // NotFoundError s'il est inconnu
212
+ if (isFinalStatus(latest.status)) {
213
+ console.log(isFailedStatus(latest.status) ? `Échec : ${latest.errorCode}` : 'Remis');
214
+ }
215
+ ```
216
+
217
+ Statuts (`MessageStatus`) : `queued` → `submitted` → `sent` → `delivered`, ou `failed`, `rejected`, `expired`,
218
+ `undeliverable` (définitifs). `unknown` désigne un statut ajouté à l'API après cette version du SDK. Helpers :
219
+ `isPendingStatus()`, `isFinalStatus()`, `isFailedStatus()`.
220
+
221
+ ### Plusieurs destinataires : tout ou rien
222
+
223
+ ```ts
224
+ import { RecipientNotSendableError } from '@labyrinthe/connect';
225
+
226
+ try {
227
+ const batch = await client.sms.sendMany({
228
+ recipients: ['0812345678', '0899999999', '+33612345678'],
229
+ body: 'Réunion demain à 9h.',
230
+ reference: 'meeting-2026-11-07',
231
+ });
232
+ batch.messages; // un Message par numéro distinct (doublons fusionnés après normalisation)
233
+ batch.idempotencyKey; // la clé utilisée (la vôtre ou générée)
234
+ batch.replayed; // true si l'API a rejoué un envoi identique précédent
235
+ batch.requestId; // X-Request-Id de la réponse
236
+ } catch (error) {
237
+ if (error instanceof RecipientNotSendableError) {
238
+ // RIEN n'a été envoyé ni facturé : retirez ces numéros et renvoyez.
239
+ for (const { recipient, reason } of error.rejected) console.log(recipient, reason); // invalid_phone | blacklisted | opted_out
240
+ } else {
241
+ throw error;
242
+ }
243
+ }
244
+ ```
245
+
246
+ Jusqu'à 1000 destinataires par appel. Chaque destinataire, le Sender ID et le solde sont vérifiés **avant** tout
247
+ envoi : un seul numéro refusé (ou un solde insuffisant) et aucun SMS ne part. Les numéros que le SDK sait déjà
248
+ invalides sont refusés localement (`InvalidArgumentError`, avec leur position dans `recipients`).
249
+
250
+ ### Coût avant envoi
251
+
252
+ 1 crédit = 1 segment SMS. Un message purement ASCII (GSM-7) tient en 160 caractères (153 par segment au-delà).
253
+ **Tout caractère non ASCII** (é, à, ç, emoji, guillemets typographiques, tiret long…) passe le message en UCS-2 :
254
+ 70 caractères (67 par segment au-delà), soit un coût souvent doublé.
255
+
256
+ ```ts
257
+ const estimate = await client.sms.estimate({
258
+ body: 'Réunion demain à 9h',
259
+ recipients: ['0812345678', '0899999999'], // une chaîne ou un tableau
260
+ });
261
+ estimate.encoding; // "ucs2"
262
+ estimate.characters; // 19
263
+ estimate.segmentsPerMessage; // 1
264
+ estimate.recipientsCount; // 2 (normalisés, dédoublonnés, numéros invalides ignorés)
265
+ estimate.totalCredits; // 2
266
+ estimate.balanceAfter; // solde après l'envoi (négatif s'il est insuffisant), null si le compte n'a jamais eu de crédits
267
+ estimate.isAffordable; // balanceAfter !== null && balanceAfter >= 0
268
+
269
+ // stripAccents : accents et caractères typographiques remplacés par de l'ASCII → GSM-7, souvent moitié prix
270
+ const ascii = await client.sms.estimate({ body: 'Réunion demain à 9h', recipients: '0812345678', stripAccents: true });
271
+ ascii.encoding; // "gsm7"
272
+ ```
273
+
274
+ `stripAccents` (`false` par défaut) existe sur `send()`, `sendMany()`, `estimate()` et `otp.send()`. La
275
+ translittération est faite par le serveur : ne l'utilisez pas quand le texte exact compte. `estimate()` n'envoie rien.
276
+
277
+ ### Lister les messages
278
+
279
+ ```ts
280
+ const page = await client.sms.list({
281
+ status: 'failed', // MessageStatus
282
+ recipient: '0812345678', // tout format, normalisé
283
+ reference: 'order-1042', // correspondance exacte
284
+ from: '2026-11-01', // inclus : une date, une date-heure ou une Date
285
+ to: new Date(), // inclus : une date couvre toute la journée
286
+ page: 1, // 1 par défaut
287
+ perPage: 50, // 1 à 100, 15 par défaut
288
+ });
289
+ page.data; // Message[]
290
+ page.total; page.currentPage; page.lastPage; page.perPage;
291
+ page.hasMore; // currentPage < lastPage
292
+ const next = await page.nextPage(); // Page | null
293
+
294
+ // Tous les messages de toutes les pages, chargées à la demande :
295
+ for await (const message of client.sms.list({ reference: 'order-1042' })) {
296
+ console.log(message.createdAt, message.status);
297
+ }
298
+ ```
299
+
300
+ `list()` renvoie une `PagePromise` : `await` pour une page, ou `for await` pour tout parcourir (une `Page` est aussi
301
+ itérable de manière asynchrone, à partir de cette page). La liste ne contient que les messages envoyés **avec la clé
302
+ API appelante** ; `get(id)` trouve tout message du compte par son `id`.
303
+
304
+ ## Solde et Sender IDs
305
+
306
+ ```ts
307
+ const { balance, held } = await client.balance.get();
308
+ // balance : crédits disponibles ; held : crédits réservés par les campagnes en cours
309
+
310
+ const senderIds = await client.senderIds.list(); // noms approuvés uniquement, utilisables comme `sender`
311
+ for (const s of senderIds) console.log(s.value, s.isDefault, s.status); // "MYSHOP" true "approved"
312
+ ```
313
+
314
+ `SenderId` : `id`, `value` (≤ 11 caractères), `status`, `purpose`, `rejectionReason`, `isDefault`, `createdAt`. Les
315
+ Sender IDs se demandent et s'approuvent dans l'espace client.
316
+
317
+ ## Vérifier un numéro par OTP
318
+
319
+ Le code est généré, envoyé par SMS et vérifié par Labyrinthe Connect. Il est stocké haché : l'API ne le renvoie jamais
320
+ en production et le SDK ne le journalise jamais.
321
+
322
+ ```ts
323
+ import { Labyrinthe } from '@labyrinthe/connect';
324
+
325
+ const client = new Labyrinthe();
326
+
327
+ // 1. Envoyer le code. Gardez otpId CÔTÉ SERVEUR (session, cache, base), jamais dans le navigateur.
328
+ const otp = await client.otp.send({ phone: '0812345678' });
329
+ req.session.otpId = otp.otpId;
330
+ otp.expiresAt; // Date
331
+ otp.maxAttempts; // 5
332
+ otp.codeLength; // 6 — affichez autant de cases (4 à 8, paramètres OTP du compte)
333
+ otp.codeType; // "numeric" | "alphanumeric" (saisie insensible à la casse)
334
+ otp.resendAfterSeconds; // 60 — compte à rebours « Renvoyer le code »
335
+ otp.testCode; // le code avec une clé lsk_test_, null avec une clé live
336
+
337
+ // 2. L'utilisateur saisit le code reçu par SMS.
338
+
339
+ // 3. Le vérifier. Un code faux n'est PAS une exception.
340
+ const result = await client.otp.verify({ otpId: req.session.otpId, code: req.body.code });
341
+ if (result.verified) {
342
+ delete req.session.otpId;
343
+ // connecter l'utilisateur / marquer le numéro comme vérifié
344
+ } else if (result.reason === 'invalid_code') {
345
+ console.log(`Encore ${String(result.attemptsRemaining)} essai(s)`); // laisser réessayer
346
+ } else {
347
+ // 'expired' | 'already_used' | 'max_attempts_reached' : demander un nouveau code
348
+ }
349
+ ```
350
+
351
+ Options de `otp.send()` :
352
+
353
+ | Option | Description |
354
+ |---|---|
355
+ | `phone` | Obligatoire. Tout format accepté (voir [Numéros de téléphone](#numéros-de-téléphone)). |
356
+ | `template` | Doit contenir `{code}` ; `{app_name}`, `{minutes}` facultatifs. ≤ 1600 caractères. Défaut : paramètres OTP du compte. |
357
+ | `locale` | `'fr'` ou `'en'`, langue du modèle standard. Défaut : paramètres OTP du compte. |
358
+ | `webOtpDomain` | Hôte de votre site pour la Web OTP API (ci-dessous). Validé localement, mis en minuscules. |
359
+ | `stripAccents` | SMS en ASCII seulement (GSM-7). `false` par défaut. |
360
+ | `idempotencyKey` | ≤ 255 caractères ; générée si absente (voir [Relances](#relances-et-idempotence)). |
361
+
362
+ `OtpSendResult` expose aussi `idempotencyKey`, `replayed` (un rejeu renvoie le même `otpId`, sans nouveau SMS) et
363
+ `requestId`. `testCode` est masqué dans `console.log()` et `JSON.stringify()`.
364
+
365
+ `otp.verify({ otpId, code })` renvoie `{ verified, reason, attemptsRemaining }` et est **gratuit** :
366
+
367
+ | `verified` | `reason` | `attemptsRemaining` | Que faire |
368
+ |---|---|---|---|
369
+ | `true` | `null` | `null` | Faire confiance au numéro. |
370
+ | `false` | `invalid_code` | entier | Code faux, un essai consommé : laisser réessayer. |
371
+ | `false` | `max_attempts_reached` | `0` | Code bloqué : en envoyer un nouveau. |
372
+ | `false` | `expired` | `null` | Envoyer un nouveau code. |
373
+ | `false` | `already_used` | `null` | Chaque code est à usage unique : en envoyer un nouveau. |
374
+
375
+ Un `otpId` inconnu (ou d'un autre compte) lève `NotFoundError`. L'API ignore les espaces et la casse du code.
376
+
377
+ Règles et limites :
378
+
379
+ - Un nouvel `send()` au même numéro invalide le code précédent. Après une vérification réussie, un nouvel envoi est
380
+ accepté immédiatement.
381
+ - Longueur (4 à 8), alphabet, validité, essais, délai de renvoi, Sender ID et modèle viennent des paramètres OTP du
382
+ compte (espace client > *Vérification OTP*).
383
+ - `OtpResendTooSoonError` (429 `OTP_RESEND_TOO_SOON`) : un code a été envoyé à ce numéro trop récemment ; affichez un
384
+ compte à rebours de `retryAfterMs`. `OtpRateLimitedError` (429 `OTP_RATE_LIMITED`) : plafond horaire atteint pour ce
385
+ numéro ou pour tout le compte (`scope` : `'phone' | 'account'`, `limit`, `retryAfterMs`). Aucune des deux n'est
386
+ relancée par le SDK.
387
+ - `otp/send` est limité à 10 requêtes par minute et par clé API (en plus des 120 par minute générales).
388
+ - L'issue de chaque vérification est aussi notifiée par les webhooks `otp.verified` / `otp.failed`.
389
+
390
+ ### Web OTP (remplissage automatique sur Android)
391
+
392
+ Avec `webOtpDomain`, l'API termine le SMS par la ligne `@app.example.com #123456`, le format de la
393
+ [Web OTP API](https://developer.mozilla.org/docs/Web/API/WebOTP_API) : Chrome sur Android propose alors de remplir le
394
+ code dans votre page servie depuis **exactement cet hôte**, en HTTPS (page de premier niveau).
395
+
396
+ ```ts
397
+ await client.otp.send({ phone: '0812345678', webOtpDomain: 'app.example.com' }); // hôte seul : ni schéma, ni port, ni chemin
398
+ ```
399
+
400
+ ```js
401
+ // Dans le navigateur (@labyrinthe/otp-widget le fait pour vous)
402
+ const { code } = await navigator.credentials.get({ otp: { transport: ['sms'] } });
403
+ ```
404
+
405
+ ## Webhooks
406
+
407
+ Configurez un point de terminaison dans l'espace client (*Développeurs > Webhooks*) et copiez son secret (il n'existe
408
+ pas d'API de gestion des webhooks). Les événements arrivent en `POST` JSON `{ id, event, data, timestamp }`, signés :
409
+ `X-Signature: sha256=<HMAC-SHA256(secret, corps brut) en hexadécimal>`. Le SDK vérifie la signature en temps constant
410
+ sur le **corps brut**, avant tout parsing, puis le `timestamp` signé (5 minutes par défaut) pour bloquer les rejeux.
411
+
412
+ Répondez `2xx` en moins de 10 secondes (mettez le travail long en file). Sinon la livraison est retentée jusqu'à
413
+ 5 fois (après 30 s, 2 min, 5 min, 15 min) : **dédoublonnez sur `event.id`**, stable d'une tentative à l'autre. L'ordre
414
+ n'est pas garanti.
415
+
416
+ | `event` | `data` |
417
+ |---|---|
418
+ | `message.status_changed` | `messageId`, `recipient`, `status`, `providerMessageId`, `campaignId`, `reference`, `errorCode`, `errorMessage` |
419
+ | `campaign.completed` | `campaignId`, `name`, `recipientsCount`, `messagesByStatus`, `creditsCharged`, `completedAt` |
420
+ | `balance.low` | `balance`, `threshold` |
421
+ | `otp.verified` | `otpId`, `phone`, `status` (`verified`), `reason` (`null`), `verifiedAt` — jamais le code |
422
+ | `otp.failed` | mêmes champs : `status` `failed` (`reason` `max_attempts_reached`) ou `expired` (`reason` `expired`), `verifiedAt` `null` |
423
+ | `test.ping` | `{}` — envoyé par le bouton *Tester* de l'espace client |
424
+ | `unknown` | un événement ajouté après cette version du SDK : son nom dans `type`, son contenu dans `data` (tel quel). Répondez 2xx. |
425
+
426
+ Chaque événement a aussi `id`, `type` (le nom tel qu'envoyé), `timestamp` (`Date`) et `raw` (l'enveloppe décodée).
427
+ Faites un `switch` sur `event.event` pour obtenir un `data` typé.
428
+
429
+ ### Express
430
+
431
+ ```ts
432
+ import express from 'express';
433
+ import { labyrintheWebhook } from '@labyrinthe/connect/express';
434
+
435
+ const app = express();
436
+
437
+ // AVANT express.json() (ou avec express.raw({ type: 'application/json' }) sur cette route) : il faut le corps brut.
438
+ app.post('/webhooks/labyrinthe', labyrintheWebhook({
439
+ secret: process.env.LABYRINTHE_WEBHOOK_SECRET, // par défaut
440
+ toleranceSeconds: 300, // par défaut ; 0 désactive le contrôle d'horodatage
441
+ onEvent: async (event) => {
442
+ if (!(await markProcessed(event.id))) return; // votre dédoublonnage (ex. insertion unique) : déjà traité
443
+ switch (event.event) {
444
+ case 'message.status_changed':
445
+ await updateMessage(event.data.messageId, event.data.status);
446
+ break;
447
+ case 'otp.failed':
448
+ await flagPhone(event.data.phone, event.data.reason);
449
+ break;
450
+ }
451
+ },
452
+ }));
453
+
454
+ app.use(express.json());
455
+ ```
456
+
457
+ Signature ou contenu invalide → `400` (`{ error: { code, message } }`). `onEvent` se termine → `200 { received: true }`
458
+ (sauf si vous avez déjà répondu). `onEvent` lève une erreur → `next(error)` (500, la livraison sera retentée). Sans
459
+ `onEvent`, l'événement vérifié est placé dans `req.labyrintheEvent` et `next()` est appelé. Fonctionne aussi avec
460
+ Connect et `node:http`.
461
+
462
+ ### Next.js (App Router)
463
+
464
+ ```ts
465
+ // app/api/webhooks/labyrinthe/route.ts
466
+ import { labyrintheWebhookHandler } from '@labyrinthe/connect/next';
467
+
468
+ export const POST = labyrintheWebhookHandler({
469
+ secret: process.env.LABYRINTHE_WEBHOOK_SECRET, // par défaut
470
+ onEvent: async (event) => {
471
+ if (event.event === 'balance.low') {
472
+ await alertFinance(event.data.balance, event.data.threshold);
473
+ }
474
+ // ne rien renvoyer → 200 { received: true } ; ou renvoyer votre propre Response
475
+ },
476
+ });
477
+ ```
478
+
479
+ Fonctionne sur les runtimes Node.js et Edge, et partout où un handler reçoit une `Request` Fetch et renvoie une
480
+ `Response` (Hono, `Bun.serve`, `Deno.serve`…). Signature invalide → `400` ; aucun secret configuré → `500`
481
+ `WEBHOOK_SECRET_MISSING` ; `onEvent` lève une erreur → `500` `WEBHOOK_HANDLER_FAILED` (journalisée avec l'id de
482
+ l'événement ; la livraison sera retentée).
483
+
484
+ ### Tout runtime (Cloudflare Workers, Deno, Bun…)
485
+
486
+ ```ts
487
+ import { InvalidWebhookPayloadError, SignatureVerificationError, Webhooks } from '@labyrinthe/connect';
488
+
489
+ export default {
490
+ async fetch(request: Request, env: { LABYRINTHE_WEBHOOK_SECRET: string }): Promise<Response> {
491
+ try {
492
+ const event = await Webhooks.constructEvent(
493
+ await request.arrayBuffer(), // corps BRUT : string, Uint8Array/Buffer ou ArrayBuffer
494
+ request.headers.get('x-signature'),
495
+ env.LABYRINTHE_WEBHOOK_SECRET,
496
+ { toleranceSeconds: 300 },
497
+ );
498
+ // … traiter l'événement (dédoublonner sur event.id)
499
+ return new Response('ok');
500
+ } catch (error) {
501
+ if (error instanceof SignatureVerificationError || error instanceof InvalidWebhookPayloadError) {
502
+ return new Response('invalid', { status: 400 });
503
+ }
504
+ throw error;
505
+ }
506
+ },
507
+ };
508
+ ```
509
+
510
+ `Webhooks.constructEvent()` est asynchrone (Web Crypto) et n'accepte jamais un objet déjà parsé. Il lève
511
+ `SignatureVerificationError` (signature absente ou fausse, horodatage hors tolérance) ou `InvalidWebhookPayloadError`
512
+ (corps signé qui n'est pas une enveloppe) : répondez `400`. `Webhooks.verifySignature(body, header, secret)` renvoie un
513
+ booléen ; `Webhooks.sign(body, secret)` construit une valeur `X-Signature` valide pour tester votre point de
514
+ terminaison. `Webhooks.SIGNATURE_HEADER` vaut `'X-Signature'`.
515
+
516
+ ## Erreurs
517
+
518
+ Toutes les erreurs héritent de `LabyrintheError` et ont `code` (stable : c'est sur lui qu'il faut brancher),
519
+ `message`, `status` (statut HTTP, ou `null` sans réponse) et `details` (toujours un objet). Les erreurs de l'API
520
+ héritent de `ApiError` et exposent aussi `headers`, `requestId` et `apiVersion`. Les messages de l'API sont en
521
+ français, pour les humains ; la liste des codes est ouverte, gardez donc toujours une branche générique.
522
+
523
+ | Classe | HTTP | `code` | Que faire |
524
+ |---|---|---|---|
525
+ | `AuthenticationError` | 401 | `UNAUTHENTICATED` | Clé absente, invalide ou révoquée : vérifiez `LABYRINTHE_API_KEY`. |
526
+ | `PermissionError` | 403 | `FORBIDDEN`, `OTP_DISABLED_FOR_ACCOUNT` | IP non autorisée pour cette clé ; OTP désactivé pour le compte. |
527
+ | `AccountSuspendedError` | 403 | `ACCOUNT_SUSPENDED` | Toute l'API est fermée : contactez le support avec `requestId`. Hérite de `PermissionError`. |
528
+ | `NotFoundError` | 404 | `NOT_FOUND` | Id de message ou `otpId` inconnu (ou d'un autre compte). |
529
+ | `ValidationError` | 422 | `VALIDATION_ERROR` | Corrigez les champs de `fieldErrors` (`{ body: ['…'] }`). |
530
+ | `RecipientNotSendableError` | 422 | `RECIPIENT_NOT_SENDABLE` | Rien n'est parti : retirez `rejected` (`reason` : `invalid_phone`, `blacklisted`, `opted_out`). |
531
+ | `SenderIdNotApprovedError` | 422 | `SENDER_ID_NOT_APPROVED` | Utilisez un nom de `senderIds.list()`. |
532
+ | `InvalidPhoneError` | 422 | `INVALID_PHONE` | Numéro OTP inutilisable : demandez-en un autre. |
533
+ | `ApiError` | 422 | `INVALID_OTP_TEMPLATE` | Votre `template` est invalide (doit contenir `{code}`). |
534
+ | `InsufficientCreditsError` | 409 | `INSUFFICIENT_CREDITS` | Rien n'est parti : achetez des crédits (`available`, `required`). Jamais en mode test. |
535
+ | `IdempotencyKeyReusedError` | 409 | `IDEMPOTENCY_KEY_REUSED` | Même clé, autre contenu : nouvelle clé pour un nouveau message. |
536
+ | `IdempotencyRequestInProgressError` | 409 | `IDEMPOTENCY_REQUEST_IN_PROGRESS` | Déjà relancée par le SDK ; attendez `retryAfterMs` et rappelez avec la **même** clé. |
537
+ | `IdempotencyKeyUnresolvedError` | 409 | `IDEMPOTENCY_KEY_UNRESOLVED` | Issue inconnue, un SMS a pu partir : vérifiez, puis utilisez une **nouvelle** clé ([ci-dessous](#relances-et-idempotence)). |
538
+ | `RateLimitError` | 429 | `TOO_MANY_REQUESTS` | Déjà relancée quand c'était sans risque ; attendez `retryAfterMs`. |
539
+ | `OtpResendTooSoonError` | 429 | `OTP_RESEND_TOO_SOON` | Affichez un compte à rebours de `retryAfterMs`. Hérite de `RateLimitError`. |
540
+ | `OtpRateLimitedError` | 429 | `OTP_RATE_LIMITED` | Plafond horaire (`scope`, `limit`, `retryAfterMs`). Hérite de `RateLimitError`. |
541
+ | `FeatureDisabledError` | 503 | `FEATURE_DISABLED`, `OTP_SERVICE_DISABLED` | Maintenance : rien n'a été fait ([ci-dessous](#maintenance-503-feature_disabled)). Hérite de `ServiceUnavailableError`. |
542
+ | `ServiceUnavailableError` | 5xx | tout code | Déjà relancée quand c'était sans risque ; réessayez plus tard. |
543
+ | `ApiError` | autre | `HTTP_ERROR` (405), codes inconnus | Journalisez `code`, `status`, `requestId`. |
544
+ | `ConnectionError` | — | `CONNECTION_ERROR` | API injoignable (DNS, TLS…). Les envois ont déjà été relancés ; rappeler avec la même `idempotencyKey` est sans risque. |
545
+ | `TimeoutError` | — | `TIMEOUT` | Une tentative a dépassé `timeoutMs`. Hérite de `ConnectionError`. |
546
+ | `RequestAbortedError` | — | `REQUEST_ABORTED` | Annulé par `signal`. Jamais relancé. |
547
+ | `InvalidArgumentError` | — | `INVALID_ARGUMENT` | Saisie invalide détectée avant tout appel (numéro, longueur, option). |
548
+ | `UnexpectedResponseError` | 2xx | `UNEXPECTED_RESPONSE` | Réponse illisible pour le SDK (page d'un proxy…). |
549
+ | `SignatureVerificationError` | — | `SIGNATURE_VERIFICATION_FAILED` | Signature ou horodatage de webhook invalide : répondez 400. |
550
+ | `InvalidWebhookPayloadError` | — | `INVALID_WEBHOOK_PAYLOAD` | Corps de webhook signé qui n'est pas une enveloppe : répondez 400. |
551
+
552
+ ```ts
553
+ import { ApiError, InsufficientCreditsError, LabyrintheError, ValidationError } from '@labyrinthe/connect';
554
+
555
+ try {
556
+ await client.sms.send({ to: '0812345678', body: 'Bonjour' });
557
+ } catch (error) {
558
+ if (error instanceof InsufficientCreditsError) {
559
+ await alertFinance(error.available, error.required);
560
+ } else if (error instanceof ValidationError) {
561
+ console.error(error.fieldErrors);
562
+ } else if (error instanceof ApiError) {
563
+ console.error(error.code, error.status, error.requestId); // requestId pour le support
564
+ } else if (error instanceof LabyrintheError) {
565
+ console.error(error.code); // CONNECTION_ERROR, TIMEOUT, INVALID_ARGUMENT…
566
+ } else {
567
+ throw error;
568
+ }
569
+ }
570
+ ```
571
+
572
+ ### Maintenance (503 `FEATURE_DISABLED`)
573
+
574
+ Un administrateur de la plateforme peut couper temporairement toute l'API (`blockedBy === 'api'` : tous les appels,
575
+ refusés avant même la vérification de la clé) ou une fonction (`'api.sms_send'`, `'api.otp_send'`,
576
+ `'api.otp_verify'`). L'appel lève `FeatureDisabledError` et **rien n'a été fait** : aucun SMS, aucun crédit consommé.
577
+ Le service OTP entier coupé lève la même classe avec le code historique `OTP_SERVICE_DISABLED`
578
+ (`feature === blockedBy === 'otp'` ; la vérification des codes déjà envoyés reste possible).
579
+
580
+ | Propriété | Type | Description |
581
+ |---|---|---|
582
+ | `feature` | `string \| null` | Fonction demandée par l'appel (`api`, `api.sms_send`, `api.otp_send`, `api.otp_verify`, `otp`… liste ouverte). |
583
+ | `blockedBy` | `string \| null` | Fonction réellement coupée : `feature` elle-même ou une parente (`api`). |
584
+ | `adminMessage` | `string \| null` | Message de l'administrateur (`details.message`) ; `error.message` le reprend ou reste générique. |
585
+ | `until` | `Date \| null` | Réactivation automatique programmée ; `null` si inconnue. |
586
+ | `retryAfter` / `retryAfterMs` | `number \| null` | Temps restant jusque-là (en-tête `Retry-After`), en secondes / millisecondes. |
587
+
588
+ Le SDK ne la relance jamais par backoff : seulement si `Retry-After` est présent et ne dépasse pas `maxRetryWaitMs`,
589
+ après exactement ce délai (pour tous les appels, `otp.verify()` compris, puisque la requête a été refusée avant
590
+ traitement). Sinon, traitez-la comme un échec temporaire :
591
+
592
+ ```ts
593
+ import { FeatureDisabledError } from '@labyrinthe/connect';
594
+
595
+ try {
596
+ await client.sms.send({ to, body, idempotencyKey: `order-${orderId}-ready` });
597
+ } catch (error) {
598
+ if (error instanceof FeatureDisabledError) {
599
+ // Même clé d'idempotence plus tard : rien ne peut partir deux fois.
600
+ await queue.retryLater({ orderId }, { at: error.until ?? new Date(Date.now() + 15 * 60_000) });
601
+ logger.warn('Maintenance Labyrinthe', {
602
+ feature: error.feature, blockedBy: error.blockedBy, message: error.adminMessage, requestId: error.requestId,
603
+ });
604
+ } else {
605
+ throw error;
606
+ }
607
+ }
608
+ ```
609
+
610
+ ## Relances et idempotence
611
+
612
+ Le SDK relance automatiquement (2 relances par défaut, backoff exponentiel 0,5 s → 1 s → 2 s… avec jitter, plafonné
613
+ par `maxRetryWaitMs`) **uniquement quand un rejeu ne peut pas produire deux fois un effet** :
614
+
615
+ | Appel | Erreur réseau / délai dépassé | 5xx | 429 `TOO_MANY_REQUESTS` | 409 `IDEMPOTENCY_REQUEST_IN_PROGRESS` |
616
+ |---|---|---|---|---|
617
+ | `sms.get`, `sms.list`, `sms.estimate`, `balance.get`, `senderIds.list` | relancé | relancé | après `Retry-After` | — |
618
+ | `sms.send`, `sms.sendMany`, `otp.send` (toujours avec une `Idempotency-Key`) | relancé, même clé | relancé, même clé | après `Retry-After` | après `Retry-After`, même clé |
619
+ | `otp.verify` | **jamais** (un essai a pu être consommé) | **jamais** | après `Retry-After` | — |
620
+
621
+ - `503 FEATURE_DISABLED` / `OTP_SERVICE_DISABLED` : pour tous les appels, seulement après un `Retry-After` ≤
622
+ `maxRetryWaitMs`, jamais par backoff (voir [Maintenance](#maintenance-503-feature_disabled)).
623
+ - Jamais relancés : les autres 4xx, `OTP_RESEND_TOO_SOON`, `OTP_RATE_LIMITED`, `ACCOUNT_SUSPENDED`,
624
+ `IDEMPOTENCY_KEY_UNRESOLVED`. Quand l'API demande d'attendre plus que `maxRetryWaitMs`, l'erreur est levée tout de
625
+ suite.
626
+
627
+ **Idempotence.** Chaque envoi de SMS ou d'OTP porte une `Idempotency-Key`. Sans `idempotencyKey`, le SDK génère un
628
+ UUID (`crypto.randomUUID()`) une fois par appel et le réutilise pour ses propres relances : une erreur réseau ne
629
+ produit jamais de second SMS. Pour protéger aussi **vos** relances (jobs de file, double clic, plantage), passez une
630
+ clé dérivée de votre événement métier. Un rejeu dans les 24 heures renvoie la réponse d'origine sans nouvel envoi ni
631
+ débit (`replayed: true`) :
632
+
633
+ ```ts
634
+ const batch = await client.sms.sendMany({
635
+ recipients: ['0812345678'], body: 'Votre commande 1042 est prête.', idempotencyKey: 'order-1042-ready',
636
+ });
637
+ batch.replayed; // true s'il s'agit d'un rejeu
638
+
639
+ const otp = await client.otp.send({ phone: '0812345678', idempotencyKey: `login-${loginAttemptId}` });
640
+ otp.replayed; // true : même otpId (et même testCode en mode test), aucun nouveau SMS
641
+ ```
642
+
643
+ Les clés sont propres à votre clé API et à l'opération (`/sms` et `/otp/send` ont des espaces de clés séparés), font
644
+ 255 caractères au maximum et sont liées au contenu **normalisé** : la même clé avec un autre contenu lève
645
+ `IdempotencyKeyReusedError`. Une requête terminée en erreur n'enregistre pas la clé.
646
+
647
+ **Issue inconnue.** `IdempotencyKeyUnresolvedError` (409 `IDEMPOTENCY_KEY_UNRESOLVED`) signifie que la requête
648
+ d'origine avec cette clé a échoué de façon inattendue côté API : un SMS a peut-être déjà été envoyé. Le SDK ne la
649
+ relance jamais ; ne la relancez pas automatiquement non plus. Vérifiez d'abord, puis renvoyez avec une **nouvelle**
650
+ clé (la même clé renverra toujours cette erreur) :
651
+
652
+ ```ts
653
+ import { IdempotencyKeyUnresolvedError } from '@labyrinthe/connect';
654
+
655
+ try {
656
+ await client.sms.send({ to, body, reference, idempotencyKey });
657
+ } catch (error) {
658
+ if (error instanceof IdempotencyKeyUnresolvedError) {
659
+ const { data } = await client.sms.list({ reference });
660
+ if (data.length === 0) await client.sms.send({ to, body, reference, idempotencyKey: crypto.randomUUID() });
661
+ } else {
662
+ throw error;
663
+ }
664
+ }
665
+ ```
666
+
667
+ Pour un OTP, demandez à l'utilisateur si le code est arrivé avant d'en envoyer un nouveau.
668
+
669
+ **Limites de débit.** 120 requêtes par minute par clé API, 10 par minute par clé sur `otp/send`, 120 par minute par
670
+ adresse IP. `onResponse` expose `rateLimit: { limit, remaining }` (en-têtes `X-RateLimit-*`).
671
+
672
+ ## Mode test, journalisation, identifiants de requête et types
673
+
674
+ ### Mode test
675
+
676
+ Avec une clé `lsk_test_…`, le fournisseur est simulé : rien n'est envoyé, aucun crédit n'est consommé, statuts et
677
+ réponses ont la forme de la production. Deux différences : le solde n'est pas vérifié (`INSUFFICIENT_CREDITS` n'arrive
678
+ jamais en mode test) et `otp.send()` renvoie le code dans `testCode`, pour tester tout le parcours OTP.
679
+ `client.isTestMode` indique le mode utilisé. Utilisez des clés de test en développement, en CI et dans les exemples.
680
+
681
+ ### Journalisation et secrets
682
+
683
+ Passez `logger: console` (ou pino, winston…) pour recevoir des entrées `debug` (chaque requête et réponse : méthode,
684
+ chemin, statut, durée, `replayed`, `requestId`), `warn` (relances) et `info` / `error` (erreurs de l'API et du
685
+ réseau). Les logs ne contiennent jamais la clé API, les codes OTP, les corps de requête ou de réponse, ni les numéros
686
+ complets (les paramètres `recipient` / `phone` sont masqués). Les messages d'erreur réseau ne citent jamais la query
687
+ de l'URL. `console.log()` / `JSON.stringify()` du client, de ses ressources et de `OtpSendResult` masquent la clé et
688
+ `testCode`.
689
+
690
+ ### Identifiants de requête
691
+
692
+ Chaque réponse de l'API porte `X-Request-Id` : `error.requestId` sur les erreurs, `requestId` sur les lots de
693
+ `sendMany()` et les résultats de `otp.send()`, et `onResponse` pour tout appel. Communiquez-le au support. Vous pouvez
694
+ envoyer le vôtre (réutilisé à chaque relance de l'appel) :
695
+
696
+ ```ts
697
+ await client.sms.send({ to: '0812345678', body: 'Bonjour' }, {
698
+ requestId: 'order-1042',
699
+ onResponse: ({ requestId, apiVersion, rateLimit, replayed }) => logger.debug({ requestId, apiVersion, rateLimit, replayed }),
700
+ });
701
+ ```
702
+
703
+ Le `User-Agent` est `labyrinthe-node/<version> <runtime>/<version>` ; `VERSION` est exportée.
704
+
705
+ ### Types TypeScript
706
+
707
+ Tout est exporté depuis `@labyrinthe/connect` :
708
+
709
+ - Objets : `Message`, `MessageBatch`, `Estimate`, `Balance`, `SenderId`, `RejectedRecipient`, `OtpSendResult`,
710
+ `OtpVerifyResult` (discriminé sur `verified`), `Page`, `PagePromise`, `ResponseInfo`, `RequestOptions`,
711
+ `LabyrintheOptions`.
712
+ - Paramètres : `SendSmsParams`, `SendManySmsParams`, `EstimateSmsParams`, `ListSmsParams`, `SendOtpParams`,
713
+ `VerifyOtpParams`, `OtpLocale`.
714
+ - Enums (objet constant + type union du même nom) : `MessageStatus`, `Encoding`, `SenderIdStatus`, `RejectionReason`,
715
+ `OtpFailureReason`, `OtpCodeType`, `OtpVerificationStatus`, `OtpRateLimitScope`, `WebhookEventType`. Tous sauf
716
+ `Encoding` et `WebhookEventType` ont un membre `unknown` pour les valeurs ajoutées plus tard à l'API.
717
+ - Webhooks : `WebhookEvent` (union), `MessageStatusChangedEvent`, `CampaignCompletedEvent`, `BalanceLowEvent`,
718
+ `OtpVerifiedEvent`, `OtpFailedEvent`, `OtpWebhookData`, `TestPingEvent`, `UnknownEvent`, `RawBody`,
719
+ `ConstructEventOptions`.
720
+ - Erreurs : toutes les classes ci-dessus, `ApiErrorCode`, `SdkErrorCode`, `ErrorCode`, `ErrorDetails`.
721
+ - Transport : `FetchLike`, `FetchInit`, `FetchResponse`, `Logger`.
722
+
723
+ ```ts
724
+ import { MessageStatus, type Message } from '@labyrinthe/connect';
725
+
726
+ const failed = (m: Message) => m.status === MessageStatus.Failed;
727
+ ```
728
+
729
+ ## Sans le package (HTTP brut)
730
+
731
+ L'API est du JSON sur HTTPS ; le SDK n'ajoute que la validation, les relances, le typage et les helpers de webhook.
732
+ Avec `fetch` (Node 18+) :
733
+
734
+ ```ts
735
+ const API = 'https://api.labyrinthe-rdc.com/api/public/v1';
736
+ const headers = {
737
+ Authorization: `Bearer ${process.env.LABYRINTHE_API_KEY}`, // lsk_test_… pendant les tests
738
+ 'Content-Type': 'application/json',
739
+ Accept: 'application/json',
740
+ };
741
+
742
+ // Envoyer un SMS (202). Idempotency-Key : rejouez sans risque avec la MÊME clé pendant 24 h.
743
+ const res = await fetch(`${API}/sms`, {
744
+ method: 'POST',
745
+ headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() },
746
+ body: JSON.stringify({ recipient: '+243812345678', body: 'Votre commande 1042 est prête.', reference: 'order-1042' }),
747
+ });
748
+ const json = await res.json();
749
+ if (!res.ok) {
750
+ // { error: { code, message, details } }
751
+ throw new Error(`${json.error.code} (HTTP ${res.status}, requête ${res.headers.get('x-request-id')})`);
752
+ }
753
+ const [message] = json.data; // [{ id, recipient, status: "queued", segments, credits_charged, … }]
754
+
755
+ // OTP : envoi (201), puis vérification (200)
756
+ const sent = await (await fetch(`${API}/otp/send`, {
757
+ method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() },
758
+ body: JSON.stringify({ phone: '+243812345678', locale: 'fr' }),
759
+ })).json(); // { data: { otp_id, expires_at, max_attempts, code_length, code_type, resend_after_seconds, test_code? } }
760
+
761
+ const checked = await (await fetch(`${API}/otp/verify`, {
762
+ method: 'POST', headers, body: JSON.stringify({ otp_id: sent.data.otp_id, code: '123456' }),
763
+ })).json(); // { data: { verified, reason, attempts_remaining } }
764
+ ```
765
+
766
+ Signature d'un webhook avec `node:crypto`, sur le corps brut :
767
+
768
+ ```ts
769
+ import { createHmac, timingSafeEqual } from 'node:crypto';
770
+
771
+ function verifyWebhook(rawBody: Buffer, signatureHeader: string | undefined, secret: string): boolean {
772
+ const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'));
773
+ const given = Buffer.from(signatureHeader ?? '');
774
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) return false;
775
+ const { timestamp } = JSON.parse(rawBody.toString('utf8'));
776
+ return Math.abs(Date.now() / 1000 - timestamp) <= 300; // protection contre le rejeu
777
+ }
778
+ ```
779
+
780
+ Référence : la spécification OpenAPI 3.1
781
+ [`openapi/openapi.yaml`](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/blob/main/openapi/openapi.yaml)
782
+ (aussi servie par l'API sur `https://api.labyrinthe-rdc.com/api/public/openapi.yaml`) et la
783
+ [collection Postman](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/tree/main/postman).
784
+
785
+ ## Mise à jour
786
+
787
+ - **1.1.x → 1.2.0** (API 1.3.0) : aucun changement de code. Nouvelle `FeatureDisabledError` pour 503
788
+ `FEATURE_DISABLED` ; 503 `OTP_SERVICE_DISABLED` est désormais levée elle aussi en `FeatureDisabledError`. Les deux
789
+ restent `instanceof ServiceUnavailableError` : vos branches `catch` existantes fonctionnent toujours. Elles ne sont
790
+ plus relancées par backoff, seulement après un `Retry-After` court.
791
+ - **1.1.0 → 1.1.1** : les numéros partent vers l'API en E.164 avec `+` (les numéros étrangers courts gardent leur
792
+ indicatif). Ajout de `IdempotencyKeyUnresolvedError`. Aucun changement de code.
793
+ - **1.0.x → 1.1.0** (API 1.2.0) : `OtpVerifyResult` gagne `attemptsRemaining` (adaptez les comparaisons profondes du
794
+ résultat entier) ; `otp.send()` est idempotent et relancé sans risque ; les numéros sans `+` de 11 chiffres ou plus
795
+ ne commençant pas par 0 restent internationaux (`33612345678`).
796
+
797
+ Détails : [CHANGELOG](CHANGELOG.md). Dans la v1, l'API n'apporte que des ajouts compatibles (champs, codes,
798
+ événements) : le SDK ignore ce qu'il ne connaît pas.
799
+
800
+ ## Contribuer
801
+
802
+ Tickets et pull requests vont dans le monorepo
803
+ [Labyrinthe-Team/labyrinthe-connect-sdks](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks)
804
+ (voir [CONTRIBUTING.md](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/blob/main/CONTRIBUTING.md)). Dans
805
+ `node/` :
806
+
807
+ ```bash
808
+ npm ci
809
+ npm run check # lint + types + tests (fetch simulé, contrat OpenAPI) + build + check:dist + publint + attw
810
+
811
+ # tests d'intégration contre un backend local, avec une clé de TEST uniquement
812
+ LABYRINTHE_API_KEY=lsk_test_… LABYRINTHE_BASE_URL=http://localhost:8000 npm run test:integration
813
+ ```
814
+
815
+ Failles de sécurité : écrivez à connect@labyrinthe-rdc.com plutôt que d'ouvrir un ticket public.
816
+
817
+ ## Licence
818
+
819
+ MIT — voir [LICENSE](LICENSE).