@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/CHANGELOG.md +101 -0
- package/LICENSE +21 -0
- package/README.fr.md +819 -0
- package/README.md +808 -2
- package/dist/express.cjs +87 -0
- package/dist/express.d.cts +49 -0
- package/dist/express.d.ts +49 -0
- package/dist/express.js +85 -0
- package/dist/index.cjs +1721 -0
- package/dist/index.d.cts +943 -0
- package/dist/index.d.ts +943 -0
- package/dist/index.js +1667 -0
- package/dist/next.cjs +48 -0
- package/dist/next.d.cts +31 -0
- package/dist/next.d.ts +31 -0
- package/dist/next.js +46 -0
- package/package.json +119 -4
package/README.fr.md
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
# Labyrinthe Connect — SDK Node.js / TypeScript
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@labyrinthe/connect)
|
|
4
|
+
[](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/actions/workflows/node.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+

|
|
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).
|