unismsgateway 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,26 +35,31 @@ import { init, getSmsPlatform, reset, smsPlatform } from 'unismsgateway';
35
35
  ### `IgatewaySettings`
36
36
 
37
37
 
38
- | Field | Type | Description |
39
- | ------------ | --------------- | -------------------------------------- |
40
- | `platformId` | `'route' \| 'hubtel' \| 'nest'` | Which gateway to use. |
41
- | `param` | `IgatewayParam` | Provider-specific options (see below). |
38
+ | Field | Type | Description |
39
+ | ------------ | ----------------------------- | -------------------------------------- |
40
+ | `platformId` | `'route' | 'hubtel' | 'nest'` | Which gateway to use. |
41
+ | `param` | `IgatewayParam` | Provider-specific options (see below). |
42
42
 
43
43
 
44
44
  ### `IgatewayParam` (all fields optional except what your `platformId` requires)
45
45
 
46
46
 
47
- | Field | Type | Used by | Description |
48
- | -------------- | --------- | --------------- | ---------------------------------------------------------------------- |
49
- | `username` | `string` | `route` | Route Mobile account username. **Required** for `route`. |
50
- | `password` | `string` | `route` | Route Mobile account password. **Required** for `route`. |
51
- | `host` | `string` | `route`, `nest` | API host. See per-gateway defaults below. |
52
- | `port` | `number` | `route` | TCP port for Route Mobile. Default: `8080`. |
53
- | `protocol` | `'http' \| 'https'` | `route`, `nest` | HTTPS or HTTP to the provider API. |
54
- | `clientId` | `string` | `hubtel` | Hubtel client ID. **Required** for `hubtel`. |
55
- | `clientSecret` | `string` | `hubtel` | Hubtel client secret. **Required** for `hubtel`. |
56
- | `apiKey` | `string` | `nest` | SMSOnlineGH API key (`Authorization: key …`). **Required** for `nest`. |
57
- | `debug` | `boolean` | all | If `true`, the active gateway logs each request/response to the console (prefix `[unismsgateway:…]`). Off by default. |
47
+ | Field | Type | Used by | Description |
48
+ | -------------- | ------------------ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
+ | `username` | `string` | `route` | Route Mobile account username. **Required** for `route`. |
50
+ | `password` | `string` | `route` | Route Mobile account password. **Required** for `route`. |
51
+ | `host` | `string` | `route`, `nest` | API host. See per-gateway defaults below. |
52
+ | `port` | `number` | `route` | TCP port for Route Mobile. Default: `8080`. |
53
+ | `protocol` | `'http' | 'https'` | `route`, `nest` | HTTPS or HTTP to the provider API. |
54
+ | `clientId` | `string` | `hubtel` | Hubtel client ID. **Required** for `hubtel`. |
55
+ | `clientSecret` | `string` | `hubtel` | Hubtel client secret. **Required** for `hubtel`. |
56
+ | `apiKey` | `string` | `nest` | SMSOnlineGH API key (`Authorization: key …`). **Required** for `nest`. |
57
+ | `debug` | `boolean` | all | If `true`, the active gateway logs each request/response to the console (prefix `[unismsgateway:…]`). Off by default. |
58
+ | `keepAlive` | `boolean` | `nest` | Enable HTTP keep-alive connection pooling. Reuses TCP/TLS sockets across calls, eliminating per-request handshake overhead. Stale-socket errors are recovered automatically via `retries`. Default: `true`. |
59
+ | `timeout` | `number` | `nest` | Request deadline in milliseconds. The request is aborted with an `ETIMEDOUT` error if the server does not respond within this window. Default: `10000`. |
60
+ | `maxSockets` | `number` | `nest` | Maximum concurrent sockets in the keep-alive pool. Default: `10`. |
61
+ | `retries` | `number` | `nest` | Automatic retry attempts on transient socket errors (`ECONNRESET`, `ECONNABORTED`, `EPIPE`, `ETIMEDOUT`). Default: `1`. |
62
+ | `deliveryCallback` | `{ url: string; accept?: 'application/json' \| 'application/xml' }` | `nest` | Optional SMSOnlineGH [delivery push](https://dev.smsonlinegh.com/docs/v5/http/rest/messaging/delivery_push.html) webhook. When set, every `quickSend` and `send` includes a `callback` block in the send payload. `accept` defaults to `application/json`. Receiving webhook POSTs is your app's responsibility. |
58
63
 
59
64
 
60
65
  Validation runs in `smsPlatform` when the instance is constructed: missing required fields for the chosen `platformId` throw `Error` with a clear message.
@@ -79,11 +84,11 @@ There are **two separate contexts**. Use the section that matches what you are d
79
84
  Nothing is read from the environment unless **you** wire it. Required fields are determined only by `platformId`:
80
85
 
81
86
 
82
- | `platformId` | Required in `param` | Optional in `param` (defaults in this library) |
83
- | ------------ | -------------------------- | --------------------------------------------------------------------------------------------- |
84
- | `nest` | `apiKey` | `host` (default `api.smsonlinegh.com`), `protocol` (default `https`), `debug` |
85
- | `hubtel` | `clientId`, `clientSecret` | `debug` |
86
- | `route` | `username`, `password` | `host` (default `rslr.connectbind.com`), `protocol` (default `http`), `port` (default `8080`), `debug` |
87
+ | `platformId` | Required in `param` | Optional in `param` (defaults in this library) |
88
+ | ------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
89
+ | `nest` | `apiKey` | `host` (default `api.smsonlinegh.com`), `protocol` (default `https`), `debug`, `keepAlive` (default `true`), `timeout` (default `10000` ms), `maxSockets` (default `10`), `retries` (default `1`), `deliveryCallback` (optional delivery push webhook) |
90
+ | `hubtel` | `clientId`, `clientSecret` | `debug` |
91
+ | `route` | `username`, `password` | `host` (default `rslr.connectbind.com`), `protocol` (default `http`), `port` (default `8080`), `debug` |
87
92
 
88
93
 
89
94
  **Suggested env names for your app** (optional; you can rename them). Credential keys (`NEST_`*, `HUBTEL_`*, `ROUTE_*`) match [live test](#live-integration-test-environment-variables) and `.env.example`. Platform selection differs: the test script requires `GATEWAY_PLATFORM` (or `TEST_ALL`); in your app you choose any name (the example below uses `SMS_PLATFORM_ID`):
@@ -94,6 +99,8 @@ Nothing is read from the environment unless **you** wire it. Required fields are
94
99
  | `NEST_API_KEY` | `apiKey` | `nest` |
95
100
  | `NEST_HOST` | `host` | optional for `nest` |
96
101
  | `NEST_PROTOCOL` | `protocol` | optional for `nest` |
102
+ | `NEST_DELIVERY_CALLBACK_URL` | `deliveryCallback.url` | optional for `nest` |
103
+ | `NEST_DELIVERY_CALLBACK_ACCEPT` | `deliveryCallback.accept` | optional for `nest` (`application/json` or `application/xml`; default `application/json`) |
97
104
  | `HUBTEL_CLIENT_ID` | `clientId` | `hubtel` |
98
105
  | `HUBTEL_CLIENT_SECRET` | `clientSecret` | `hubtel` |
99
106
  | `ROUTE_USERNAME` | `username` | `route` |
@@ -128,7 +135,13 @@ const paramByPlatform = {
128
135
  nest: {
129
136
  apiKey: process.env.NEST_API_KEY,
130
137
  host: process.env.NEST_HOST,
131
- protocol: process.env.NEST_PROTOCOL
138
+ protocol: process.env.NEST_PROTOCOL,
139
+ deliveryCallback: process.env.NEST_DELIVERY_CALLBACK_URL
140
+ ? {
141
+ url: process.env.NEST_DELIVERY_CALLBACK_URL,
142
+ accept: process.env.NEST_DELIVERY_CALLBACK_ACCEPT
143
+ }
144
+ : undefined
132
145
  }
133
146
  };
134
147
 
@@ -163,6 +176,8 @@ The script `scripts/test-live.ts` loads `.env` (copy from `.env.example`) and ex
163
176
  | `NEST_API_KEY` | **Yes** | Maps to `param.apiKey`. |
164
177
  | `NEST_HOST` | No | Overrides default host `api.smsonlinegh.com`. |
165
178
  | `NEST_PROTOCOL` | No | Overrides default `https`. |
179
+ | `NEST_DELIVERY_CALLBACK_URL` | No | Maps to `param.deliveryCallback.url`. When set, sends include SMSOnlineGH delivery push callback info. |
180
+ | `NEST_DELIVERY_CALLBACK_ACCEPT` | No | Maps to `param.deliveryCallback.accept` (`application/json` or `application/xml`; default `application/json`). |
166
181
 
167
182
 
168
183
  `**hubtel`**
@@ -189,12 +204,16 @@ The script `scripts/test-live.ts` loads `.env` (copy from `.env.example`) and ex
189
204
  #### Live send (optional; all gateways)
190
205
 
191
206
 
192
- | Variable | Required? | Purpose |
193
- | -------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ |
194
- | `TEST_SEND` | No (default: do not send) | Set to `true` to call `quickSend()` and send a real SMS. If unset or not `true`, only init and balance checks run. |
195
- | `TEST_FROM` | **Yes** when `TEST_SEND=true` | `QuickSendParams.From`. |
196
- | `TEST_TO` | **Yes** when `TEST_SEND=true` | `QuickSendParams.To`. |
197
- | `TEST_CONTENT` | No | Message body; if omitted, the script uses a built-in default string. |
207
+ | Variable | Required? | Purpose |
208
+ | ------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ |
209
+ | `TEST_SEND` | No (default: do not send) | Set to `true` to run live send checks. If unset or not `true`, only init and balance checks run. |
210
+ | `TEST_SEND_METHOD` | No (default: `quickSend`) | `quickSend` — single recipient via `quickSend()`. `send` — multiple recipients via `send()` (`nest`, `route`). `both` — run both. `sendPersonalized` — personalised bulk via `sendPersonalized()` (`nest` only). |
211
+ | `TEST_FROM` | **Yes** when `TEST_SEND=true` | Sender ID (`From` / `from`). |
212
+ | `TEST_TO` | **Yes** when method is `quickSend` or `both` | Single recipient for `quickSend()`. Also used as a one-element array for `send()` when `TEST_TO_MULTI` is omitted. |
213
+ | `TEST_TO_MULTI` | Recommended for `send` / `both` | Comma-separated MSISDNs for `send()` (e.g. `233...,233...`). Required when `TEST_SEND_METHOD=send` unless `TEST_TO` is set. |
214
+ | `TEST_CONTENT` | No | Message body; if omitted, the script uses a built-in default string. |
215
+ | `TEST_PERSONALIZED_TEMPLATE` | No (default template used) | Message template for `sendPersonalized()` (e.g. `Hello {$name}. Your balance is ${$balance}.`). |
216
+ | `TEST_PERSONALIZED_DESTINATIONS` | **Yes** when `TEST_SEND_METHOD=sendPersonalized` | JSON array of `{ to, values }` objects, e.g. `[{"to":"233...","values":["Name",123]}]`. |
198
217
 
199
218
 
200
219
  ---
@@ -211,7 +230,7 @@ The script `scripts/test-live.ts` loads `.env` (copy from `.env.example`) and ex
211
230
  - Calls `createGateway()` to instantiate the underlying provider (`routeSms`, `HubtelSms`, or `NestSmsGateway`).
212
231
  3. `**getSmsPlatform(): smsPlatform | null`**: Returns the current singleton, or `null` if `reset()` was called and no new `init()` has run.
213
232
 
214
- There is **no async bootstrap**; after `init()` returns, `quickSend` is ready.
233
+ There is **no async bootstrap**; after `init()` returns, `quickSend`, `send`, and `sendPersonalized` are ready.
215
234
 
216
235
  ---
217
236
 
@@ -300,20 +319,150 @@ const gateway = unisms.init({
300
319
  **Optional `param`:**
301
320
 
302
321
 
303
- | Field | Default if omitted |
304
- | ---------- | --------------------- |
305
- | `host` | `api.smsonlinegh.com` |
306
- | `protocol` | `'https'` |
322
+ | Field | Default if omitted | Notes |
323
+ | ------------ | --------------------- | --------------------------------------------------------------------- |
324
+ | `host` | `api.smsonlinegh.com` | |
325
+ | `protocol` | `'https'` | |
326
+ | `keepAlive` | `true` | Set to `false` to open a fresh TCP/TLS connection per request. |
327
+ | `timeout` | `10000` | Milliseconds before the request is aborted with `ETIMEDOUT`. |
328
+ | `maxSockets` | `10` | Maximum sockets held open in the keep-alive pool. |
329
+ | `retries` | `1` | Retry count for transient errors (`ECONNRESET`, `ECONNABORTED`, etc). |
330
+ | `deliveryCallback` | — | Optional delivery push webhook. When set, each send request includes `callback: { url, accept }` per [SMSOnlineGH delivery push docs](https://dev.smsonlinegh.com/docs/v5/http/rest/messaging/delivery_push.html). `accept` defaults to `application/json`. This library registers the URL with the provider only; your app must expose and handle the webhook endpoint. |
307
331
 
308
332
 
309
- Requests use `POST` to path `**/v5/<endpoint>`** (e.g. send: `message/sms/send`, balance: `account/balance`). Authorization header: `Authorization: key <apiKey>`. Each request opens a fresh connection (keep-alive pooling is disabled) to prevent stale-socket errors in long-running processes.
333
+ Requests use `POST` to path `/v5/<endpoint>` (e.g. send: `message/sms/send`, balance: `account/balance`). Authorization header: `Authorization: key <apiKey>`.
334
+
335
+ `NestSmsGateway` maintains a private keep-alive connection pool (`https.Agent`) so TCP and TLS handshakes are paid once and subsequent requests reuse warm sockets. If the server closes an idle socket between calls and the first write fails with `ECONNABORTED` or `ECONNRESET`, the library retries automatically on a fresh socket (controlled by `retries`). Set `keepAlive: false` to revert to a per-request connection if your network environment requires it.
336
+
337
+ The `destroy()` method on `NestSmsGateway` releases all pooled sockets; call it during application shutdown so Node does not hold the event loop open. Access it via `getGateway()`:
338
+
339
+ ```javascript
340
+ const gateway = unisms.init({
341
+ platformId: 'nest',
342
+ param: { apiKey: 'your-api-key' }
343
+ });
344
+
345
+ // On app shutdown:
346
+ gateway.getGateway().destroy();
347
+ ```
348
+
349
+ **Example with delivery push callback:**
350
+
351
+ ```javascript
352
+ const gateway = unisms.init({
353
+ platformId: 'nest',
354
+ param: {
355
+ apiKey: 'your-api-key',
356
+ deliveryCallback: {
357
+ url: 'https://your-app.example/sms/delivery',
358
+ accept: 'application/json' // optional; default application/json
359
+ }
360
+ }
361
+ });
362
+ ```
363
+
364
+ SMSOnlineGH POSTs delivery status to that URL asynchronously after the operator reports delivery. The initial `quickSend` response still only reflects submission status.
365
+
366
+ **Delivery push callback payload**
367
+
368
+ A delivery push notification uses the same response shape as a [Message Delivery Report](https://dev.smsonlinegh.com/docs/v5/http/rest/messaging/delivery_report.html), but applies to **one destination only**. Expect a single item in `data.destinations` (even when the original send had multiple recipients).
369
+
370
+ The `Content-Type` of the POST matches your configured `deliveryCallback.accept` (`application/json` by default, or `application/xml`).
371
+
372
+ **JSON** (`accept: 'application/json'`):
373
+
374
+ ```json
375
+ {
376
+ "handshake": {
377
+ "id": 0,
378
+ "label": "HSHK_OK"
379
+ },
380
+ "data": {
381
+ "batch": "cfa19ba67f94fbd6b19c067b0c87ed4f",
382
+ "delivery": true,
383
+ "category": "sms",
384
+ "text": "Hello world!",
385
+ "type": 0,
386
+ "sender": "Hello",
387
+ "personalised": false,
388
+ "destinationsCount": 2,
389
+ "destinations": [
390
+ {
391
+ "to": "233246314915",
392
+ "id": "093841e5-578a-41f4-5f5f-2f3910886c12",
393
+ "country": "Ghana",
394
+ "messageCount": 1,
395
+ "submitDateTime": "2021-09-29 21:57:44",
396
+ "reportDateTime": "2021-09-29 21:57:48",
397
+ "status": {
398
+ "id": 2110,
399
+ "label": "DS_DELIVERED"
400
+ }
401
+ }
402
+ ]
403
+ }
404
+ }
405
+ ```
406
+
407
+ **XML** (`accept: 'application/xml'`):
408
+
409
+ ```xml
410
+ <response>
411
+ <handshake>
412
+ <id>0</id>
413
+ <label>HSHK_OK</label>
414
+ </handshake>
415
+ <data>
416
+ <batch>cfa19ba67f94fbd6b19c067b0c87ed4f</batch>
417
+ <category>sms</category>
418
+ <delivery>true</delivery>
419
+ <text>Hello world!</text>
420
+ <type>0</type>
421
+ <sender>Hello</sender>
422
+ <personalised>false</personalised>
423
+ <destinationsCount>2</destinationsCount>
424
+ <destinations>
425
+ <item>
426
+ <to>233246314915</to>
427
+ <id>093841e5-578a-41f4-5f5f-2f3910886c12</id>
428
+ <country>Ghana</country>
429
+ <messageCount>1</messageCount>
430
+ <submitDateTime>2021-09-29 21:57:44</submitDateTime>
431
+ <reportDateTime>2021-09-29 21:57:48</reportDateTime>
432
+ <status>
433
+ <id>2110</id>
434
+ <label>DS_DELIVERED</label>
435
+ </status>
436
+ </item>
437
+ </destinations>
438
+ </data>
439
+ </response>
440
+ ```
441
+
442
+ Fields your webhook handler will typically use:
443
+
444
+ | Path | Description |
445
+ | ---- | ----------- |
446
+ | `handshake.label` | `HSHK_OK` when the payload is valid. |
447
+ | `data.batch` | Batch ID from the original send (matches `SendResult.messageId` / `data.batch` from `quickSend`). |
448
+ | `data.destinations[0].to` | Recipient phone number for this delivery event. |
449
+ | `data.destinations[0].id` | Per-destination message ID. |
450
+ | `data.destinations[0].status.id` / `status.label` | Delivery outcome (e.g. `2110` / `DS_DELIVERED`). |
451
+ | `data.destinations[0].submitDateTime` | When the message was submitted to the operator. |
452
+ | `data.destinations[0].reportDateTime` | When the operator reported this delivery status. |
453
+
454
+ Respond with HTTP `200` promptly so SMSOnlineGH does not retry the push. Persist and process the payload asynchronously if your handler does heavy work.
455
+
456
+ **Example with performance tuning:**
310
457
 
311
458
  ```javascript
312
459
  const gateway = unisms.init({
313
460
  platformId: 'nest',
314
461
  param: {
315
- apiKey: 'your-api-key'
316
- // optional: host, protocol
462
+ apiKey: 'your-api-key',
463
+ timeout: 5000, // abort after 5 s
464
+ maxSockets: 20, // higher pool ceiling for burst traffic
465
+ retries: 2 // extra resilience on flaky networks
317
466
  }
318
467
  });
319
468
  ```
@@ -335,20 +484,24 @@ console.log(balance.balance, balance.model);
335
484
 
336
485
  ## Sending messages
337
486
 
487
+ Use **`quickSend()`** for a single recipient. Use **`send()`** when the active gateway supports batch delivery to multiple numbers in one request (`nest`, `route`). Use **`sendPersonalized()`** when each recipient needs a customised message from one template (`nest` only). **`hubtel`** does not support `send()` or `sendPersonalized()` — call `quickSend()` per recipient instead.
488
+
338
489
  ### `QuickSendParams`
339
490
 
340
- | Field | Type | Required | Description |
341
- | --------- | -------- | -------- | ---------------------------------------------------------------------- |
342
- | `From` | `string` | yes | Sender ID or label. |
343
- | `To` | `string \| number` | yes | Recipient MSISDN or number. |
344
- | `Content` | `string` | yes | Message body. |
345
- | `Type` | `number` | no | Message type; **nest** maps this to request body `type` (default `0`). |
346
491
 
347
- **camelCase:** You may pass **`from`**, **`to`**, **`content`**, and **`type`** instead of the PascalCase names above. Many JavaScript projects use camelCase; if you pass only `content` and `Content` is missing, the SMSOnlineGH (`nest`) API receives no message body and may return handshake **1305** (`MV_ERR_MESSAGE` — missing or invalid message body). The library normalizes both conventions before calling the gateway.
492
+ | Field | Type | Required | Description |
493
+ | --------- | ----------------- | -------- | ---------------------------------------------------------------------- |
494
+ | `From` | `string` | yes | Sender ID or label. |
495
+ | `To` | `string | number` | yes | Recipient MSISDN or number. |
496
+ | `Content` | `string` | yes | Message body. |
497
+ | `Type` | `number` | no | Message type; **nest** maps this to request body `type` (default `0`). |
498
+
499
+
500
+ **camelCase:** You may pass `**from`**, `**to**`, `**content**`, and `**type**` instead of the PascalCase names above. Many JavaScript projects use camelCase; if you pass only `content` and `Content` is missing, the SMSOnlineGH (`nest`) API receives no message body and may return handshake **1305** (`MV_ERR_MESSAGE` — missing or invalid message body). The library normalizes both conventions before calling the gateway.
348
501
 
349
502
  ### `quickSend(params, callback?)`
350
503
 
351
- Returns `Promise<SendResult>`. Optional `callback` is invoked with the same result when the promise completes. The `params` argument accepts **`QuickSendParams`** (PascalCase) or **`QuickSendParamsCamel`** (`{ from, to, content, type? }`). See **`normalizeQuickSendParams`** in the public API if you need the same mapping outside `quickSend`.
504
+ Returns `Promise<SendResult>`. Optional `callback` is invoked with the same result when the promise completes. The `params` argument accepts `**QuickSendParams`** (PascalCase) or `**QuickSendParamsCamel**` (`{ from, to, content, type? }`). See `**normalizeQuickSendParams**` in the public API if you need the same mapping outside `quickSend`.
352
505
 
353
506
  `**SendResult`:**
354
507
 
@@ -362,9 +515,9 @@ Returns `Promise<SendResult>`. Optional `callback` is invoked with the same resu
362
515
  }
363
516
  ```
364
517
 
365
- When `success` is `false`, always read **`error`** — it contains a human-readable reason (provider status codes, API handshake labels, network errors, and so on). For **`nest`**, if the API rejects the send but returns JSON, **`data`** is the **full parsed response body** (not only `response.data`), so you can inspect `handshake` and any provider fields. For HTTP errors, `data` may be the raw response body string. **`statusCode`** is set when the adapter knows the HTTP status (for example nest).
518
+ When `success` is `false`, always read `**error**` — it contains a human-readable reason (provider status codes, API handshake labels, network errors, and so on). For `**nest**`, if the API rejects the send but returns JSON, `**data**` is the **full parsed response body** (not only `response.data`), so you can inspect `handshake` and any provider fields. For HTTP errors, `data` may be the raw response body string. `**statusCode`** is set when the adapter knows the HTTP status (for example nest).
366
519
 
367
- **Debugging:** Set `param.debug: true` when calling `init()` to print request URLs, bodies, and responses to the console. The live test script enables debug for the `nest` platform so you can trace `quickSend` and `getBalance` without changing application code.
520
+ **Debugging:** Set `param.debug: true` when calling `init()` to print request URLs, bodies, and responses to the console. The live test script enables debug for the `nest` platform so you can trace `quickSend`, `send`, and `getBalance` without changing application code.
368
521
 
369
522
  **Example**
370
523
 
@@ -407,6 +560,102 @@ gateway.quickSend(
407
560
  );
408
561
  ```
409
562
 
563
+ ### `SendParams`
564
+
565
+ Same fields as `QuickSendParams`, except `To` is an array of recipients:
566
+
567
+
568
+ | Field | Type | Required | Description |
569
+ | --------- | ----------------------- | -------- | ---------------------------------------------------------------------- |
570
+ | `From` | `string` | yes | Sender ID or label. |
571
+ | `To` | `(string \| number)[]` | yes | One or more recipient MSISDNs or numbers. |
572
+ | `Content` | `string` | yes | Message body (same content to all recipients). |
573
+ | `Type` | `number` | no | Message type; **nest** maps this to request body `type` (default `0`). |
574
+
575
+
576
+ **camelCase:** Pass `{ from, to, content, type? }` where `to` is an array. See `normalizeSendParams`.
577
+
578
+ **Platform behaviour**
579
+
580
+ | Platform | `send()` support | Notes |
581
+ | -------- | ---------------- | ----- |
582
+ | `nest` | Yes | One HTTP request with `destinations: string[]`. `messageId` is the batch ID; per-recipient status is in `data.destinations`. |
583
+ | `route` | Yes | Passes `To` as `number[]` to Route Mobile. `success` is true only when every destination succeeds; partial failures set `error` with a summary. |
584
+ | `hubtel` | No | Throws — use `quickSend()` for each recipient. |
585
+
586
+ ### `send(params, callback?)`
587
+
588
+ Returns `Promise<SendResult>`. Same result shape and callback semantics as `quickSend`. Accepts `SendParams` (PascalCase) or `SendParamsCamel` (`{ from, to: [...], content, type? }`).
589
+
590
+ **Example (`nest`)**
591
+
592
+ ```javascript
593
+ const result = await gateway.send({
594
+ From: 'TEST',
595
+ To: ['0246314915', '0242053072'],
596
+ Content: 'Hello from unismsgateway!',
597
+ Type: 0
598
+ });
599
+
600
+ if (result.success) {
601
+ console.log('Batch:', result.messageId);
602
+ console.log('Destinations:', result.data?.destinations);
603
+ }
604
+ ```
605
+
606
+ ### Personalized bulk send
607
+
608
+ Use **`sendPersonalized()`** when each recipient should receive a **different** message body derived from one template. Placeholders in the template use SMSOnlineGH variable syntax (`{$name}`, `${$balance}`, etc.); per-recipient substitution values are passed as positional arrays in the same order variables appear in the template. See [SMSOnlineGH Personalised Messaging](https://dev.smsonlinegh.com/docs/v5/http/rest/messaging/sms_personalised.html?tabs=json%2Ctabid-1).
609
+
610
+ ### `PersonalizedSendParams`
611
+
612
+ | Field | Type | Required | Description |
613
+ | --------------- | ------------------------- | -------- | --------------------------------------------------------------------------- |
614
+ | `From` | `string` | yes | Sender ID or label. |
615
+ | `Content` | `string` | yes | Message template with `{$variable}` placeholders. |
616
+ | `Destinations` | `PersonalizedRecipient[]` | yes | One entry per recipient with `To` and positional `Values`. |
617
+ | `Type` | `number` | no | Message type; **nest** maps this to request body `type` (default `0`). |
618
+
619
+ Each `PersonalizedRecipient`:
620
+
621
+ | Field | Type | Required | Description |
622
+ | -------- | ----------------------- | -------- | ------------------------------------------------ |
623
+ | `To` | `string \| number` | yes | Recipient MSISDN or number. |
624
+ | `Values` | `(string \| number)[]` | yes | Substitution values in template variable order. |
625
+
626
+ **camelCase:** Pass `{ from, content, destinations: [{ to, values }], type? }`. See `normalizePersonalizedSendParams`.
627
+
628
+ **Platform behaviour**
629
+
630
+ | Platform | `sendPersonalized()` support | Notes |
631
+ | -------- | ---------------------------- | ----- |
632
+ | `nest` | Yes | One HTTP request with `destinations: [{ number, values }]`. Same endpoint as `send()`; `messageId` is the batch ID; per-recipient status is in `data.destinations`. |
633
+ | `route` | No | Throws — use `send()` with the same content for all recipients. |
634
+ | `hubtel` | No | Throws — use `quickSend()` per recipient. |
635
+
636
+ ### `sendPersonalized(params, callback?)`
637
+
638
+ Returns `Promise<SendResult>`. Same result shape and callback semantics as `send`. Accepts `PersonalizedSendParams` (PascalCase) or `PersonalizedSendParamsCamel`.
639
+
640
+ **Example (`nest`)**
641
+
642
+ ```javascript
643
+ const result = await gateway.sendPersonalized({
644
+ From: 'TEST',
645
+ Content: 'Hello {$name}. Your balance is ${$balance}.',
646
+ Destinations: [
647
+ { To: '0246314915', Values: ['Daniel', 560.45] },
648
+ { To: '0242053072', Values: ['Emmanuel', 348.56] }
649
+ ],
650
+ Type: 0
651
+ });
652
+
653
+ if (result.success) {
654
+ console.log('Batch:', result.messageId);
655
+ console.log('Destinations:', result.data?.destinations);
656
+ }
657
+ ```
658
+
410
659
  ---
411
660
 
412
661
  ## Testing (live integration)
@@ -429,7 +678,7 @@ npm run test:live
429
678
 
430
679
  1. **Init** — Builds `param` from your `.env`, calls `init()`, and checks configuration validation.
431
680
  2. **Balance** — For `nest` and `hubtel` only, calls `getBalance()` when the adapter supports it. `route` skips this step.
432
- 3. **Send** — **Opt-in.** By default no SMS is sent. Set `TEST_SEND=true` and the send-related variables listed in [Live integration test environment variables](#live-integration-test-environment-variables).
681
+ 3. **Send** — **Opt-in.** By default no SMS is sent. Set `TEST_SEND=true` and the send-related variables listed in [Live integration test environment variables](#live-integration-test-environment-variables). Use `TEST_SEND_METHOD` to choose `quickSend` (default), `send` (multi-destination on `nest`/`route`), `both`, or `sendPersonalized` (`nest` only). For `hubtel`, the script verifies that `send()` is rejected as expected; for `route`/`hubtel`, `sendPersonalized()` rejection is verified when that method is selected.
433
682
 
434
683
  Full variable reference (selection, per-gateway credentials, live send): [Live integration test environment variables](#live-integration-test-environment-variables). The script loads `.env` via `dotenv` (dev dependency). Exit code is `0` when all checks pass, non-zero if a step fails or no platform is selected.
435
684
 
@@ -438,38 +687,61 @@ Full variable reference (selection, per-gateway credentials, live send): [Live i
438
687
  ## API reference
439
688
 
440
689
 
441
- | Export | Description |
442
- | -------------------------- | -------------------------------------------------------------------- |
443
- | `init(settings)` | Create and register the singleton `smsPlatform`, return it. |
444
- | `getSmsPlatform()` | Current `smsPlatform` or `null` after `reset()` and before `init()`. |
445
- | `reset()` | Clear the singleton. |
446
- | `smsPlatform` | Class type for typing/advanced use. |
447
- | `QuickSendParamsInput` | Union: PascalCase `QuickSendParams` or camelCase `QuickSendParamsCamel`. |
448
- | `QuickSendParamsCamel` | `{ from, to, content, type? }` for `quickSend`. |
690
+ | Export | Description |
691
+ | -------------------------- | -------------------------------------------------------------------------- |
692
+ | `init(settings)` | Create and register the singleton `smsPlatform`, return it. |
693
+ | `getSmsPlatform()` | Current `smsPlatform` or `null` after `reset()` and before `init()`. |
694
+ | `reset()` | Clear the singleton. |
695
+ | `smsPlatform` | Class type for typing/advanced use. |
696
+ | `QuickSendParamsInput` | Union: PascalCase `QuickSendParams` or camelCase `QuickSendParamsCamel`. |
697
+ | `QuickSendParamsCamel` | `{ from, to, content, type? }` for `quickSend`. |
449
698
  | `normalizeQuickSendParams` | Maps input to canonical `QuickSendParams` (throws if body/sender missing). |
699
+ | `SendParamsInput` | Union: PascalCase `SendParams` or camelCase `SendParamsCamel`. |
700
+ | `SendParamsCamel` | `{ from, to: (string\|number)[], content, type? }` for `send`. |
701
+ | `normalizeSendParams` | Maps input to canonical `SendParams` (throws if body/sender/recipients missing). |
702
+ | `PersonalizedSendParamsInput` | Union: PascalCase `PersonalizedSendParams` or camelCase `PersonalizedSendParamsCamel`. |
703
+ | `PersonalizedSendParamsCamel` | `{ from, content, destinations: [{ to, values }], type? }` for `sendPersonalized`. |
704
+ | `PersonalizedRecipient` | `{ To, Values }` — one personalised destination. |
705
+ | `normalizePersonalizedSendParams` | Maps input to canonical `PersonalizedSendParams` (throws if template/sender/destinations missing). |
450
706
 
451
707
 
452
708
  `**smsPlatform` instance methods**
453
709
 
454
710
 
455
- | Method | Returns | Description |
456
- | ------------------------------ | --------------------- | ---------------------------------------------- |
457
- | `init()` | `ISmsGateway` | Returns `this` (facade). |
458
- | `quickSend(params, callback?)` | `Promise<SendResult>` | Normalizes PascalCase or camelCase params, then delegates to the active gateway. |
459
- | `getGateway()` | `ISmsGateway` | Underlying adapter (for nest: `getBalance()`). |
711
+ | Method | Returns | Description |
712
+ | ------------------------------ | --------------------- | -------------------------------------------------------------------------------- |
713
+ | `init()` | `ISmsGateway` | Returns `this` (facade). |
714
+ | `quickSend(params, callback?)` | `Promise<SendResult>` | Single recipient. Normalizes PascalCase or camelCase params. |
715
+ | `send(params, callback?)` | `Promise<SendResult>` | Multiple recipients (`To` array). Supported on `nest` and `route`; `hubtel` throws. |
716
+ | `sendPersonalized(params, callback?)` | `Promise<SendResult>` | Personalised bulk send (`Destinations` with per-recipient `Values`). Supported on `nest` only; `route` and `hubtel` throw. |
717
+ | `getGateway()` | `ISmsGateway` | Underlying adapter (for nest: `getBalance()`). |
460
718
 
461
719
 
462
720
  ---
463
721
 
464
722
  ## Changelog
465
723
 
724
+ ### 1.6.0
725
+
726
+ - **New:** `sendPersonalized()` for personalised bulk SMS on **`nest`** (SMSOnlineGH template variables + per-destination `values`). **`route`** and **`hubtel`** throw — use `send()` or `quickSend()` instead.
727
+ - **New:** `send()` for multi-destination SMS on **`nest`** (SMSOnlineGH `destinations` array) and **`route`** (Route Mobile `number[]`). `To` is `(string | number)[]`. **`hubtel`** throws — use `quickSend()` per recipient.
728
+ - **Performance (`nest`):** The `NestSmsGateway` now uses a persistent keep-alive connection pool (`https.Agent`) instead of opening a fresh TCP + TLS connection on every request. Subsequent sends to the same host reuse warm sockets, eliminating the per-call handshake overhead (~100–300 ms per request).
729
+ - **Reliability (`nest`):** Stale-socket errors (`ECONNRESET`, `ECONNABORTED`, `EPIPE`, `ETIMEDOUT`) that can occur when a pooled socket is reused after the server has closed it are automatically retried on a fresh connection. The default retry count is `1`; configure via `param.retries`.
730
+ - **Timeout support (`nest`):** Requests that stall mid-flight are now aborted after a configurable deadline (`param.timeout`, default `10 000 ms`) instead of hanging indefinitely.
731
+ - **New `param` fields (`nest`):** `keepAlive` (default `true`), `timeout` (default `10000`), `maxSockets` (default `10`), `retries` (default `1`). All are optional and fully backwards-compatible; existing `init()` calls require no changes.
732
+ - **Resource cleanup (`nest`):** `NestSmsGateway` exposes a `destroy()` method (accessible via `getGateway().destroy()`) that releases pooled sockets so Node does not hold the event loop open after the gateway is no longer needed.
733
+ - **Internal (`nest`):** Response chunks are now accumulated as `Buffer[]` and concatenated once at the end, avoiding repeated string re-allocation per chunk. The POST body is serialised to a `Buffer` upfront so `Content-Length` reads `Buffer.length` (O(1)) rather than rescanning the string.
734
+
466
735
  ### 1.5.2
736
+
467
737
  - **Build:** TypeScript `rootDir` is now `./src` with `include: ["src/**/*.ts"]` (scripts stay `ts-node`-only). Previously the compiler also picked up `scripts/test-live.ts`, inferred a project root above `src/`, and emitted library code under `dist/src/lib/…` while published entrypoints load `dist/lib/…` — leaving **stale or missing** `dist/lib/nest-gateway.js` (wrong `requestBody` shape). The published `dist/` layout now matches `package.json` `main` and always rebuilds gateway files from current sources.
468
738
 
469
739
  ### 1.5.1
740
+
470
741
  - **Fix (`nest` / all gateways):** `quickSend` now accepts **camelCase** (`from`, `to`, `content`, `type`) as well as PascalCase (`From`, `To`, `Content`, `Type`). Passing only camelCase previously left `Content` undefined, so the nest JSON body omitted `text` and the API returned handshake **1305** (missing or invalid message body). Validation errors throw clear messages when body or sender is empty after trim.
471
742
 
472
743
  ### 1.5.0
744
+
473
745
  - **Fix (`nest`):** `quickSend` now reliably works in long-running processes (servers, workers). Node's global HTTP agent reuses keep-alive sockets across calls; when the provider closes an idle socket server-side, the next `quickSend` that writes a request body received `write ECONNABORTED` while `getBalance` (no body) appeared to work fine. Fixed by setting `agent: false` on each request so every call opens a fresh connection rather than reusing a potentially stale one from the pool.
474
746
 
475
747
  ---
@@ -1,4 +1,4 @@
1
- import { ISmsGatewayDelegate, QuickSendParams, SendResult } from './types';
1
+ import { ISmsGatewayDelegate, QuickSendParams, SendParams, PersonalizedSendParams, SendResult } from './types';
2
2
  export interface HubtelSmsGatewayConfig {
3
3
  clientId: string;
4
4
  clientSecret: string;
@@ -13,4 +13,6 @@ export declare class HubtelSmsGateway implements ISmsGatewayDelegate {
13
13
  constructor(config: HubtelSmsGatewayConfig);
14
14
  private log;
15
15
  quickSend(params: QuickSendParams, callback?: Function): Promise<SendResult>;
16
+ send(_params: SendParams, _callback?: Function): Promise<SendResult>;
17
+ sendPersonalized(_params: PersonalizedSendParams, _callback?: Function): Promise<SendResult>;
16
18
  }
@@ -67,5 +67,15 @@ class HubtelSmsGateway {
67
67
  return result;
68
68
  });
69
69
  }
70
+ send(_params, _callback) {
71
+ return __awaiter(this, void 0, void 0, function* () {
72
+ throw new Error('Hubtel does not support send() with multiple destinations. Use quickSend() for a single recipient.');
73
+ });
74
+ }
75
+ sendPersonalized(_params, _callback) {
76
+ return __awaiter(this, void 0, void 0, function* () {
77
+ throw new Error('Hubtel does not support sendPersonalized(). Use quickSend() for a single recipient.');
78
+ });
79
+ }
70
80
  }
71
81
  exports.HubtelSmsGateway = HubtelSmsGateway;
package/dist/lib/lib.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { smsPlatform, IgatewaySettings, IgatewayParam, PlatformId, QuickSendParams, QuickSendParamsInput, QuickSendParamsCamel, normalizeQuickSendParams, SendResult, ISmsGateway } from './platform';
1
+ import { smsPlatform, IgatewaySettings, IgatewayParam, PlatformId, QuickSendParams, QuickSendParamsInput, QuickSendParamsCamel, normalizeQuickSendParams, SendParams, SendParamsInput, SendParamsCamel, normalizeSendParams, PersonalizedSendParams, PersonalizedSendParamsInput, PersonalizedSendParamsCamel, PersonalizedRecipient, normalizePersonalizedSendParams, SendResult, ISmsGateway } from './platform';
2
2
  export declare function init(settings: IgatewaySettings): smsPlatform;
3
3
  export declare function getSmsPlatform(): smsPlatform | null;
4
4
  export declare function reset(): void;
5
- export { smsPlatform, IgatewaySettings, IgatewayParam, PlatformId, QuickSendParams, QuickSendParamsInput, QuickSendParamsCamel, normalizeQuickSendParams, SendResult, ISmsGateway };
5
+ export { smsPlatform, IgatewaySettings, IgatewayParam, PlatformId, QuickSendParams, QuickSendParamsInput, QuickSendParamsCamel, normalizeQuickSendParams, SendParams, SendParamsInput, SendParamsCamel, normalizeSendParams, PersonalizedSendParams, PersonalizedSendParamsInput, PersonalizedSendParamsCamel, PersonalizedRecipient, normalizePersonalizedSendParams, SendResult, ISmsGateway };
package/dist/lib/lib.js CHANGED
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizeQuickSendParams = exports.smsPlatform = exports.reset = exports.getSmsPlatform = exports.init = void 0;
3
+ exports.normalizePersonalizedSendParams = exports.normalizeSendParams = exports.normalizeQuickSendParams = exports.smsPlatform = exports.reset = exports.getSmsPlatform = exports.init = void 0;
4
4
  const platform_1 = require("./platform");
5
5
  Object.defineProperty(exports, "smsPlatform", { enumerable: true, get: function () { return platform_1.smsPlatform; } });
6
6
  Object.defineProperty(exports, "normalizeQuickSendParams", { enumerable: true, get: function () { return platform_1.normalizeQuickSendParams; } });
7
+ Object.defineProperty(exports, "normalizeSendParams", { enumerable: true, get: function () { return platform_1.normalizeSendParams; } });
8
+ Object.defineProperty(exports, "normalizePersonalizedSendParams", { enumerable: true, get: function () { return platform_1.normalizePersonalizedSendParams; } });
7
9
  let smsPlatformInstance = null;
8
10
  function init(settings) {
9
11
  smsPlatformInstance = new platform_1.smsPlatform(settings);