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