silon-sdk 0.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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +560 -0
  3. package/dist/client.d.ts +141 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +249 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/errors.d.ts +117 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +199 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +51 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +36 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/internal/utils.d.ts +24 -0
  16. package/dist/internal/utils.d.ts.map +1 -0
  17. package/dist/internal/utils.js +58 -0
  18. package/dist/internal/utils.js.map +1 -0
  19. package/dist/pagination.d.ts +52 -0
  20. package/dist/pagination.d.ts.map +1 -0
  21. package/dist/pagination.js +80 -0
  22. package/dist/pagination.js.map +1 -0
  23. package/dist/resources/auth.d.ts +20 -0
  24. package/dist/resources/auth.d.ts.map +1 -0
  25. package/dist/resources/auth.js +41 -0
  26. package/dist/resources/auth.js.map +1 -0
  27. package/dist/resources/broadcasts.d.ts +63 -0
  28. package/dist/resources/broadcasts.d.ts.map +1 -0
  29. package/dist/resources/broadcasts.js +103 -0
  30. package/dist/resources/broadcasts.js.map +1 -0
  31. package/dist/resources/bulk.d.ts +60 -0
  32. package/dist/resources/bulk.d.ts.map +1 -0
  33. package/dist/resources/bulk.js +137 -0
  34. package/dist/resources/bulk.js.map +1 -0
  35. package/dist/resources/client-groups.d.ts +50 -0
  36. package/dist/resources/client-groups.d.ts.map +1 -0
  37. package/dist/resources/client-groups.js +97 -0
  38. package/dist/resources/client-groups.js.map +1 -0
  39. package/dist/resources/clients.d.ts +51 -0
  40. package/dist/resources/clients.d.ts.map +1 -0
  41. package/dist/resources/clients.js +99 -0
  42. package/dist/resources/clients.js.map +1 -0
  43. package/dist/resources/events.d.ts +22 -0
  44. package/dist/resources/events.d.ts.map +1 -0
  45. package/dist/resources/events.js +38 -0
  46. package/dist/resources/events.js.map +1 -0
  47. package/dist/resources/messages.d.ts +99 -0
  48. package/dist/resources/messages.d.ts.map +1 -0
  49. package/dist/resources/messages.js +167 -0
  50. package/dist/resources/messages.js.map +1 -0
  51. package/dist/resources/otp.d.ts +26 -0
  52. package/dist/resources/otp.d.ts.map +1 -0
  53. package/dist/resources/otp.js +40 -0
  54. package/dist/resources/otp.js.map +1 -0
  55. package/dist/resources/profile.d.ts +18 -0
  56. package/dist/resources/profile.d.ts.map +1 -0
  57. package/dist/resources/profile.js +40 -0
  58. package/dist/resources/profile.js.map +1 -0
  59. package/dist/resources/push.d.ts +45 -0
  60. package/dist/resources/push.d.ts.map +1 -0
  61. package/dist/resources/push.js +103 -0
  62. package/dist/resources/push.js.map +1 -0
  63. package/dist/resources/reports.d.ts +44 -0
  64. package/dist/resources/reports.d.ts.map +1 -0
  65. package/dist/resources/reports.js +127 -0
  66. package/dist/resources/reports.js.map +1 -0
  67. package/dist/resources/suppressions.d.ts +54 -0
  68. package/dist/resources/suppressions.d.ts.map +1 -0
  69. package/dist/resources/suppressions.js +79 -0
  70. package/dist/resources/suppressions.js.map +1 -0
  71. package/dist/resources/templates.d.ts +58 -0
  72. package/dist/resources/templates.d.ts.map +1 -0
  73. package/dist/resources/templates.js +96 -0
  74. package/dist/resources/templates.js.map +1 -0
  75. package/dist/resources/webhook-endpoints.d.ts +54 -0
  76. package/dist/resources/webhook-endpoints.d.ts.map +1 -0
  77. package/dist/resources/webhook-endpoints.js +96 -0
  78. package/dist/resources/webhook-endpoints.js.map +1 -0
  79. package/dist/resources/whatsapp-templates.d.ts +20 -0
  80. package/dist/resources/whatsapp-templates.d.ts.map +1 -0
  81. package/dist/resources/whatsapp-templates.js +33 -0
  82. package/dist/resources/whatsapp-templates.js.map +1 -0
  83. package/dist/types/auth.d.ts +34 -0
  84. package/dist/types/auth.d.ts.map +1 -0
  85. package/dist/types/auth.js +8 -0
  86. package/dist/types/auth.js.map +1 -0
  87. package/dist/types/broadcasts.d.ts +174 -0
  88. package/dist/types/broadcasts.d.ts.map +1 -0
  89. package/dist/types/broadcasts.js +8 -0
  90. package/dist/types/broadcasts.js.map +1 -0
  91. package/dist/types/bulk.d.ts +161 -0
  92. package/dist/types/bulk.d.ts.map +1 -0
  93. package/dist/types/bulk.js +8 -0
  94. package/dist/types/bulk.js.map +1 -0
  95. package/dist/types/client-groups.d.ts +52 -0
  96. package/dist/types/client-groups.d.ts.map +1 -0
  97. package/dist/types/client-groups.js +8 -0
  98. package/dist/types/client-groups.js.map +1 -0
  99. package/dist/types/clients.d.ts +64 -0
  100. package/dist/types/clients.d.ts.map +1 -0
  101. package/dist/types/clients.js +8 -0
  102. package/dist/types/clients.js.map +1 -0
  103. package/dist/types/events.d.ts +64 -0
  104. package/dist/types/events.d.ts.map +1 -0
  105. package/dist/types/events.js +9 -0
  106. package/dist/types/events.js.map +1 -0
  107. package/dist/types/messages.d.ts +334 -0
  108. package/dist/types/messages.d.ts.map +1 -0
  109. package/dist/types/messages.js +8 -0
  110. package/dist/types/messages.js.map +1 -0
  111. package/dist/types/otp.d.ts +61 -0
  112. package/dist/types/otp.d.ts.map +1 -0
  113. package/dist/types/otp.js +8 -0
  114. package/dist/types/otp.js.map +1 -0
  115. package/dist/types/profile.d.ts +37 -0
  116. package/dist/types/profile.d.ts.map +1 -0
  117. package/dist/types/profile.js +8 -0
  118. package/dist/types/profile.js.map +1 -0
  119. package/dist/types/push.d.ts +90 -0
  120. package/dist/types/push.d.ts.map +1 -0
  121. package/dist/types/push.js +8 -0
  122. package/dist/types/push.js.map +1 -0
  123. package/dist/types/reports.d.ts +109 -0
  124. package/dist/types/reports.d.ts.map +1 -0
  125. package/dist/types/reports.js +8 -0
  126. package/dist/types/reports.js.map +1 -0
  127. package/dist/types/suppressions.d.ts +72 -0
  128. package/dist/types/suppressions.d.ts.map +1 -0
  129. package/dist/types/suppressions.js +8 -0
  130. package/dist/types/suppressions.js.map +1 -0
  131. package/dist/types/templates.d.ts +96 -0
  132. package/dist/types/templates.d.ts.map +1 -0
  133. package/dist/types/templates.js +9 -0
  134. package/dist/types/templates.js.map +1 -0
  135. package/dist/types/webhook-endpoints.d.ts +123 -0
  136. package/dist/types/webhook-endpoints.d.ts.map +1 -0
  137. package/dist/types/webhook-endpoints.js +8 -0
  138. package/dist/types/webhook-endpoints.js.map +1 -0
  139. package/dist/types/whatsapp-templates.d.ts +59 -0
  140. package/dist/types/whatsapp-templates.d.ts.map +1 -0
  141. package/dist/types/whatsapp-templates.js +8 -0
  142. package/dist/types/whatsapp-templates.js.map +1 -0
  143. package/dist/version.d.ts +3 -0
  144. package/dist/version.d.ts.map +1 -0
  145. package/dist/version.js +3 -0
  146. package/dist/version.js.map +1 -0
  147. package/dist/webhooks.d.ts +48 -0
  148. package/dist/webhooks.d.ts.map +1 -0
  149. package/dist/webhooks.js +97 -0
  150. package/dist/webhooks.js.map +1 -0
  151. package/package.json +60 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Silon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,560 @@
1
+ # Silon Node.js SDK
2
+
3
+ [![npm version](https://img.shields.io/npm/v/silon-sdk)](https://www.npmjs.com/package/silon-sdk)
4
+
5
+ Node.js client for the [Silon](https://silon.tech) messaging platform API —
6
+ send messages on any channel (WhatsApp, SMS, email, push, web push, voice),
7
+ manage CRM contacts and groups, run bulk campaigns, consume events, and verify
8
+ webhooks. ESM-only, TypeScript-first, **zero runtime dependencies** (built on
9
+ native `fetch`, `FormData` and `node:crypto`).
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install silon-sdk
15
+ ```
16
+
17
+ Requires Node.js >= 18. The package ships compiled ESM plus `.d.ts` type
18
+ declarations; there is no CommonJS build (`require("silon-sdk")` will not work —
19
+ use `import`, or dynamic `import()` from CJS).
20
+
21
+ ## Quickstart
22
+
23
+ ```ts
24
+ import Silon from "silon-sdk";
25
+
26
+ const client = new Silon({
27
+ apiKey: "sk_live_...", // Settings → API keys; or set SILON_API_KEY
28
+ workspace: "acme", // => https://acme.silon.tech; or set SILON_WORKSPACE / SILON_BASE_URL
29
+ });
30
+
31
+ const sent = await client.messages.send({
32
+ channel: "whatsapp",
33
+ to: { client_id: "cust_001" },
34
+ content: { body: "Your order has shipped 📦" },
35
+ });
36
+ console.log(sent.id, sent.status); // e.g. "9f3e..." "queued"
37
+ ```
38
+
39
+ The API is Promise-based throughout — every operation returns a `Promise` you
40
+ `await` (this is the platform idiom; the Python SDK additionally ships a sync
41
+ client).
42
+
43
+ ## Sending
44
+
45
+ Three entry points, every channel: `messages.send` targets a single recipient
46
+ via `to`; `messages.sendBatch` sends many independent, personalised messages
47
+ in one call — inline rows via `messages` (max 500) or an uploaded CSV via
48
+ `file`; `broadcasts.create` fans one piece of content out to an `audience`
49
+ (a client group, explicit client ids, or an inline `recipients` list). On
50
+ `messages.send`, exactly one of `to` / `audience` is required, and on
51
+ `messages.sendBatch` exactly one of `messages` / `file` (the SDK validates
52
+ both client-side and throws `SilonError` before any network call).
53
+
54
+ ```ts
55
+ // Approved WhatsApp template to a raw number
56
+ await client.messages.send({
57
+ channel: "whatsapp",
58
+ to: { phone_number: "+12025550123" },
59
+ whatsapp_template: {
60
+ name: "order_confirmed",
61
+ language: "en",
62
+ variables: { body_1: "Sara", body_2: "ORD-42" },
63
+ },
64
+ provider: "meta_cloud",
65
+ });
66
+
67
+ // Personalised batch — every row is its own message (same shape as a
68
+ // messages.send body minus `audience`); the top-level channel is the
69
+ // default, a row's own channel wins. Max 500 rows, all-or-nothing: any
70
+ // invalid row 422s the whole batch (`messages[3].to.phone_number`) and
71
+ // nothing is queued.
72
+ const batch = await client.messages.sendBatch({
73
+ channel: "sms",
74
+ messages: [
75
+ { to: { phone_number: "+96550001234" }, content: { body: "Sara, table for 2 at 7pm." } },
76
+ { to: { phone_number: "+96550001235" }, content: { body: "Omar, table for 4 at 9pm." } },
77
+ {
78
+ channel: "email",
79
+ to: { email: "lulu@example.com" },
80
+ content: { subject: "Booking confirmed", body: "Lulu, table for 6 at 8pm." },
81
+ },
82
+ ],
83
+ });
84
+ // Per-row envelopes come back in request order; poll each id individually.
85
+ // Inline batches have no GET endpoint — the per-row ids are the tracking
86
+ // primitive.
87
+ for (const row of batch.messages ?? []) {
88
+ const status = await client.messages.retrieve(row.id);
89
+ console.log(row.id, status.is_sent);
90
+ }
91
+
92
+ // Batch from an uploaded CSV — request-level fields are row defaults, CSV
93
+ // columns override per row ({{name}} renders from the row). The rows expand
94
+ // asynchronously: the 202 is an aggregate (no per-row `messages`) and the
95
+ // returned id IS the bulk batch id.
96
+ const uploaded = await client.bulk.files.upload("phone_number,name\n+96550001234,Sara\n");
97
+ const fileBatch = await client.messages.sendBatch({
98
+ file: uploaded.name,
99
+ channel: "sms",
100
+ content: { body: "Hello {{name}}" },
101
+ });
102
+ console.log(fileBatch.status, fileBatch.row_count); // "queued" 1
103
+ const detail = await client.bulk.retrieve(Number(fileBatch.id)); // per-row status
104
+
105
+ // Email broadcast to a client group
106
+ const result = await client.broadcasts.create({
107
+ channel: "email",
108
+ audience: { type: "client_group", slug: "vip" },
109
+ content: { subject: "We saved you a seat", body: "<h1>Hello</h1>" },
110
+ });
111
+ console.log(result.target_count, result.skipped_count);
112
+ // Why rows were skipped, itemised (skipped_count stays the sum):
113
+ console.log(result.skipped); // { suppressed: 2, wrong_channel: 0, duplicate: 1 }
114
+
115
+ // SMS broadcast to an inline ad-hoc list (max 1,000 rows; rows may also be
116
+ // { email } or { client_id } — duplicates are deduped into skipped.duplicate)
117
+ await client.broadcasts.create({
118
+ channel: "sms",
119
+ audience: {
120
+ type: "recipients",
121
+ recipients: [{ phone_number: "+96550001234" }, { phone_number: "+96550001235" }],
122
+ },
123
+ content: { body: "Flash sale ends tonight" },
124
+ });
125
+
126
+ // Track it
127
+ const broadcast = await client.broadcasts.retrieve(result.id);
128
+ const page = await client.broadcasts.deliveries(result.id, { limit: 100 });
129
+ for await (const delivery of page.autoPaging()) {
130
+ console.log(delivery.client_id, delivery.status);
131
+ }
132
+ ```
133
+
134
+ `messages.send` with `audience` keeps working as a legacy alias, but
135
+ `broadcasts.create` is the preferred broadcast entry point.
136
+
137
+ Every `messages.send` / `messages.sendBatch` / `broadcasts.create` /
138
+ `otp.send` call carries an
139
+ `Idempotency-Key` header (auto-generated UUIDv4 unless you pass
140
+ `idempotency_key`), so automatic retries can never double-send. The key is a
141
+ request header, never part of the JSON body.
142
+
143
+ ## Scheduling and cancellation
144
+
145
+ Pass `send_at` — an ISO-8601 date-time string **with a UTC offset** (e.g.
146
+ `"2026-07-15T09:00:00+03:00"`; date-times stay strings in this SDK, so call
147
+ `.toISOString()` on a `Date` yourself) — on `messages.send`,
148
+ `broadcasts.create`, or the **file** form of `messages.sendBatch` to schedule
149
+ the send instead of dispatching immediately. It must be strictly in the
150
+ future and at most 90 days ahead; naive date-times are rejected — otherwise
151
+ the server answers `422 send-at-invalid`. The response is the normal `202`
152
+ envelope with `status: "scheduled"`, and its `id` is stable across the
153
+ lifecycle: `messages.retrieve` / `broadcasts.retrieve` resolve it before
154
+ dispatch (`"scheduled"`) and after (the normal queued/sent lifecycle).
155
+
156
+ ```ts
157
+ const scheduled = await client.messages.send({
158
+ channel: "sms",
159
+ to: { phone_number: "+96550001234" },
160
+ content: { body: "Doors open in an hour" },
161
+ send_at: new Date(Date.now() + 3600_000).toISOString(),
162
+ });
163
+ console.log(scheduled.status); // "scheduled"
164
+
165
+ // Changed your mind? Allowed while still "scheduled":
166
+ const canceled = await client.messages.cancel(scheduled.id);
167
+ console.log(canceled.status); // "canceled" — never dispatches
168
+ ```
169
+
170
+ `messages.cancel(id)` and `broadcasts.cancel(id)` return the same envelope
171
+ types as their create calls, now showing `status: "canceled"`, and emit a
172
+ `message.canceled` / `broadcast.canceled` event. Cancel is idempotent by
173
+ nature — repeating a cancel answers `200` with the canceled envelope again
174
+ (no `Idempotency-Key` is sent, and none is needed). Once the send has
175
+ dispatched (or for an immediate send's id) the server answers
176
+ `409 not-cancellable` (`ConflictError`); an unknown id is a `404`.
177
+
178
+ Notes:
179
+
180
+ - A scheduled broadcast may answer `target_count: null` /
181
+ `skipped_count: null` when its audience resolves at dispatch time.
182
+ - `send_at` on `sendBatch` works with the `file` form only (rows expand and
183
+ send at dispatch time). With inline `messages` it is rejected
184
+ `422 batch-invalid` — schedule those rows individually via
185
+ `messages.send`, which supports per-message cancel; there is no batch
186
+ cancel resource.
187
+ - Scheduled creates are idempotency-keyed exactly like immediate ones — a
188
+ replay returns the scheduled envelope.
189
+ - With an `sk_test_` key, scheduled sends simulate delivery on dispatch,
190
+ like any other test-mode send.
191
+
192
+ ## Suppressions
193
+
194
+ `client.suppressions` manages the workspace do-not-contact list. A row is an
195
+ address (E.164 phone or email, stored normalized so any formatting matches)
196
+ optionally scoped to one channel — omit `channel` to suppress the address
197
+ everywhere. It is enforced automatically on **every** send path:
198
+
199
+ - **Single-recipient sends** (`messages.send` with `to`, `otp.send`) to a
200
+ suppressed address reject `422 recipient-suppressed`
201
+ (`UnprocessableEntityError`).
202
+ - **Fan-outs** (`broadcasts.create`, `messages.sendBatch`, legacy bulk)
203
+ silently **skip** suppressed recipients — never an error. The `202`
204
+ envelope itemises why rows were skipped in an additive `skipped` object,
205
+ with `skipped_count` staying the sum:
206
+
207
+ ```jsonc
208
+ { "skipped_count": 6, "skipped": { "suppressed": 3, "wrong_channel": 2, "duplicate": 1 } }
209
+ ```
210
+
211
+ On broadcasts `skipped` is `null` exactly when `target_count` is `null`
212
+ (scheduled audience resolves at dispatch). Inline batches omit suppressed
213
+ rows from `messages`; file-form batches report their skips on the bulk
214
+ read side (`client.bulk.retrieve(id)`) once async expansion runs.
215
+
216
+ ```ts
217
+ // Honour a STOP reply on every channel:
218
+ const row = await client.suppressions.create({
219
+ address: "+96550001234",
220
+ reason: "stop", // "manual" (default) | "unsubscribe" | "hard_bounce" | "stop"
221
+ }); // omit `channel` => suppressed everywhere
222
+
223
+ // Audit the list — cursor-paginated, filterable:
224
+ const page = await client.suppressions.list({ reason: "stop", channel: "sms" });
225
+ for await (const s of page.autoPaging()) console.log(s.address, s.reason);
226
+
227
+ // The customer opted back in:
228
+ await client.suppressions.delete(row.id); // 204 — sends resume immediately
229
+ ```
230
+
231
+ `create` is idempotent by nature: a new row answers `201`, and re-creating
232
+ the same (address, channel) pair answers `200` with the **existing** object —
233
+ never an error, and no `Idempotency-Key` is needed or sent. Rows are
234
+ mode-scoped: `sk_test_` keys list, manage and enforce test suppressions only,
235
+ live keys live ones. Scopes: `suppressions:read` to list,
236
+ `suppressions:write` to create/delete.
237
+
238
+ **Transactional/legal override.** For sends that must go out regardless
239
+ (receipts, statements, legally required notices), pass
240
+ `override_suppression: true` on `messages.send` — single-recipient sends
241
+ only. The API key must carry the `suppressions:override` scope (part of no
242
+ default preset; grant it explicitly), otherwise the server rejects
243
+ `403 missing-scope`; combined with `audience`, or used on
244
+ `broadcasts.create` / `messages.sendBatch`, it is rejected
245
+ `422 override-not-allowed`. An overridden send proceeds and its delivery row
246
+ is flagged `suppression_overridden: true`.
247
+
248
+ ```ts
249
+ await client.messages.send({
250
+ channel: "email",
251
+ to: { email: "sara@example.com" }, // suppressed (unsubscribed)
252
+ content: { subject: "Your invoice", body: "…" },
253
+ override_suppression: true, // requires the suppressions:override scope
254
+ });
255
+ ```
256
+
257
+ ## Test mode
258
+
259
+ Use an `sk_test_` API key (Settings → API keys) to integrate and CI-test
260
+ without a provider account or a real message. Test requests traverse the full
261
+ pipeline — validation, scopes, rate limits, idempotency, delivery rows,
262
+ events — but never reach a provider and never bill. Every affected response
263
+ envelope (message send/status, batch, broadcast, OTP, events, webhook
264
+ endpoints, suppressions) carries `livemode: false` (`true` for live traffic).
265
+
266
+ Magic recipients force deterministic outcomes; statuses settle asynchronously
267
+ a few seconds after the `202`, so polling and webhooks behave realistically.
268
+ The always-suppressed fixtures act as if on the suppression list without a
269
+ `suppressions.create` row existing — use them to test your
270
+ `recipient-suppressed` handling:
271
+
272
+ | Recipient | Outcome |
273
+ | --- | --- |
274
+ | `+15005550001` | delivered |
275
+ | `+15005550002` | failed (simulated provider error) |
276
+ | `+15005550009` | always suppressed — single send `422 recipient-suppressed`; fan-outs skip it into `skipped.suppressed` |
277
+ | `delivered@silon.test` | delivered |
278
+ | `bounce@silon.test` | failed (simulated bounce) |
279
+ | `suppressed@silon.test` | always suppressed — single send `422 recipient-suppressed`; fan-outs skip it into `skipped.suppressed` |
280
+ | anything else | delivered |
281
+
282
+ In **live** mode a magic recipient is rejected with
283
+ `422 test-recipient-in-live`, so test fixtures can never leak into real sends.
284
+
285
+ Test-mode OTPs are never dispatched: the magic code `"000000"` always
286
+ verifies (and only it).
287
+
288
+ Webhook endpoints are fixed to one mode at create time via the `livemode`
289
+ create param (default `true`): test events deliver only to
290
+ `livemode: false` endpoints, live events only to `livemode: true` ones —
291
+ register one endpoint per mode to consume both streams.
292
+
293
+ ```ts
294
+ const test = new Silon({ apiKey: "sk_test_...", workspace: "acme" });
295
+
296
+ const sent = await test.messages.send({
297
+ channel: "sms",
298
+ to: { phone_number: "+15005550002" }, // will settle as "failed"
299
+ content: { body: "CI check" },
300
+ });
301
+ console.log(sent.livemode); // false
302
+
303
+ await test.webhookEndpoints.create({
304
+ url: "https://ci.example.com/hooks/silon",
305
+ livemode: false, // receives test events only
306
+ });
307
+ ```
308
+
309
+ ## Resources
310
+
311
+ | Resource | Methods |
312
+ | --- | --- |
313
+ | `client.messages` | `send`, `sendBatch`, `retrieve`, `cancel` |
314
+ | `client.broadcasts` | `create`, `retrieve`, `deliveries` (paginated), `cancel` |
315
+ | `client.otp` | `send`, `verify` |
316
+ | `client.clients` | `list` (deprecated bare array), `listPaginated` (paginated), `create`, `retrieve`, `update`, `replace`, `delete` |
317
+ | `client.clientGroups` | `list` (deprecated bare array), `listPaginated` (paginated), `create`, `retrieve`, `update`, `replace`, `delete` |
318
+ | `client.bulk` | `list`, `retrieve`, `send` (deprecated — use `messages.sendBatch`), `files.list`, `files.upload`, `recipients.retrieve` |
319
+ | `client.reports` | `messages`, `channels`, `clients`, `users`, `bulks`, `specificBulks`, `subscriptions`, `awsUsage`, `balance` |
320
+ | `client.whatsappTemplates` | `list`, `retrieve` |
321
+ | `client.templates` | `list` (paginated), `create`, `retrieve`, `update`, `delete` |
322
+ | `client.webhookEndpoints` | `list` (paginated), `create`, `retrieve`, `update`, `delete`, `test`, `listAttempts` (paginated) |
323
+ | `client.suppressions` | `list` (paginated), `create`, `delete` |
324
+ | `client.events` | `list` (paginated), `retrieve` |
325
+ | `client.push` | `subscribeAndroid`, `subscribeIos`, `upsertDevices`, `markRead`, `listNotifications` (deprecated), `subscribeWeb` (deprecated) |
326
+ | `client.profile` | `retrieve`, `update`, `replace` |
327
+ | `client.auth` | `signup`, `login` (deprecated) |
328
+
329
+ ## Pagination
330
+
331
+ Cursor-paginated lists (`events`, `webhookEndpoints`,
332
+ `webhookEndpoints.listAttempts`, `templates`, `broadcasts.deliveries`,
333
+ `suppressions`, `clients.listPaginated`, `clientGroups.listPaginated`)
334
+ return a `Page<T>` you can walk manually or drain with `autoPaging()`, an
335
+ async generator that fetches each following page only when the current one is
336
+ exhausted:
337
+
338
+ ```ts
339
+ const page = await client.events.list({ type: "message.failed", limit: 100 });
340
+
341
+ for (const event of page.results) { /* this page only */ }
342
+
343
+ for await (const event of page.autoPaging()) { /* every page, lazily */ }
344
+
345
+ // manual walking
346
+ if (page.hasNextPage) {
347
+ const next = await page.nextPage(); // throws SilonError on the last page
348
+ }
349
+ ```
350
+
351
+ ### CRM lists (`clients`, `clientGroups`)
352
+
353
+ The canonical CRM list routes are now plural and cursor-paginated
354
+ (`GET /api/v1/crm/clients/`, `GET /api/v1/crm/groups/`). Reach them with the
355
+ new `listPaginated()` methods, which return a `Page<T>` like every other
356
+ cursor list:
357
+
358
+ ```ts
359
+ const page = await client.clients.listPaginated({ limit: 100 });
360
+ for await (const contact of page.autoPaging()) { /* every contact, lazily */ }
361
+ ```
362
+
363
+ For **back-compat**, the original `clients.list()` / `clientGroups.list()`
364
+ still work unchanged: they return a bare `ClientProfile[]` / `ClientGroup[]`
365
+ from the (now deprecated) singular route, so existing call sites that use
366
+ `.length`, indexing, `.map`, or a plain `for…of` keep working exactly as
367
+ before. Both are marked `@deprecated` — migrate to `listPaginated()`, which
368
+ scales past the singular route's unbounded response. `create`, `retrieve`,
369
+ `update`, `replace` and `delete` now target the plural canonical routes; their
370
+ request/response shapes are unchanged.
371
+
372
+ ## Errors
373
+
374
+ Non-2xx responses reject with a typed error carrying the parsed payload:
375
+
376
+ ```ts
377
+ import Silon, { UnprocessableEntityError, RateLimitError } from "silon-sdk";
378
+
379
+ try {
380
+ await client.messages.send({ channel: "banana", to: { client_id: "x" } });
381
+ } catch (err) {
382
+ if (err instanceof UnprocessableEntityError) { // 422
383
+ console.log(err.statusCode, err.requestId);
384
+ for (const detail of err.errors) {
385
+ console.log(detail.code, detail.attr, detail.detail);
386
+ }
387
+ } else if (err instanceof RateLimitError) { // 429
388
+ console.log("retry after", err.retryAfter, "seconds");
389
+ } else {
390
+ throw err;
391
+ }
392
+ }
393
+ ```
394
+
395
+ `BadRequestError` (400), `AuthenticationError` (401), `PermissionDeniedError`
396
+ (403), `NotFoundError` (404), `ConflictError` (409, idempotency-key reuse or
397
+ cancelling a send that is no longer scheduled), `GoneError` (410, expired
398
+ OTP), `UnprocessableEntityError` (422),
399
+ `RateLimitError` (429, with `retryAfter` seconds parsed from `Retry-After` or
400
+ `RateLimit-Reset`) and `InternalServerError` (>= 500) all subclass
401
+ `APIStatusError`, which carries `statusCode`, `requestId` (`X-Request-Id`),
402
+ `errorType`, `errors`, `retryable` and the raw parsed `body`. Network failures
403
+ reject with `APIConnectionError`; timeouts with its subclass `APITimeoutError`.
404
+ Everything extends the base `SilonError`.
405
+
406
+ `err.retryable` is the error body's `retryable` bool read verbatim — `true`
407
+ iff retrying the SAME request could ever succeed (429, 5xx, an in-flight
408
+ idempotency twin), `false` for every other 4xx — and `null` when a
409
+ legacy/non-v1 body omits it (it is never recomputed from the status code).
410
+
411
+ ### Retries
412
+
413
+ Requests are retried automatically (default `maxRetries: 2`, exponential
414
+ backoff with jitter, honouring `Retry-After` / `RateLimit-Reset`) — but only
415
+ when it is safe: idempotent methods (GET/HEAD/OPTIONS/PUT/DELETE), plus POSTs
416
+ that carry an `Idempotency-Key`. The same key value is replayed on every
417
+ attempt; plain POST/PATCH are never retried.
418
+
419
+ ## Webhooks
420
+
421
+ Verify the `Silon-Signature` header on deliveries with the endpoint's
422
+ one-time `whsec_` secret — no HTTP client needed:
423
+
424
+ ```ts
425
+ import { webhooks } from "silon-sdk";
426
+
427
+ const event = webhooks.constructEvent(
428
+ rawBody, // the raw (unparsed) request body
429
+ req.headers["silon-signature"],
430
+ process.env.SILON_WEBHOOK_SECRET,
431
+ );
432
+ if (event.type === "broadcast.completed") {
433
+ console.log(event.data.sent, "delivered,", event.data.failed, "failed");
434
+ }
435
+ ```
436
+
437
+ `constructEvent` throws `WebhookSignatureVerificationError` on a missing,
438
+ stale or mismatching signature. Lower-level pieces are also exported:
439
+ `webhooks.verifySignature(payload, header, secret, tolerance?)` returns a
440
+ boolean (constant-time compare; default tolerance 300 s, `<= 0` skips the
441
+ freshness check), `webhooks.sign(secret, payload, timestamp?)` produces a
442
+ valid header for tests, and `webhooks.SIGNATURE_HEADER` is the header name.
443
+
444
+ ## Configuration
445
+
446
+ | Option | Env fallback | Default | Notes |
447
+ | ---------------- | ----------------- | ------- | --------------------------------------------------------- |
448
+ | `apiKey` | `SILON_API_KEY` | — | Required |
449
+ | `baseUrl` | `SILON_BASE_URL` | — | Wins over `workspace`; trailing `/` stripped |
450
+ | `workspace` | `SILON_WORKSPACE` | — | Expands to `https://<workspace>.silon.tech` |
451
+ | `timeoutMs` | — | `30000` | Per-attempt timeout (`AbortSignal.timeout`) |
452
+ | `maxRetries` | — | `2` | Automatic retries after the initial attempt |
453
+ | `defaultHeaders` | — | — | Merged over the computed defaults |
454
+ | `fetch` | — | global | Injectable `fetch` — testing, or on-prem custom TLS |
455
+
456
+ A base URL must be resolvable at construction time, from one of four sources
457
+ checked in this order — otherwise the constructor throws `SilonError`
458
+ immediately (as it does for a missing API key):
459
+
460
+ 1. `baseUrl` option (wins over everything)
461
+ 2. `SILON_BASE_URL` env var
462
+ 3. `workspace` option → `https://<workspace>.silon.tech`
463
+ 4. `SILON_WORKSPACE` env var → same expansion
464
+
465
+ ## On-prem / self-hosted instances
466
+
467
+ The `workspace` shortcut is SaaS-only sugar; everything else in the SDK is
468
+ host-agnostic. For a self-hosted Silon, point `baseUrl` at your instance:
469
+
470
+ ```ts
471
+ const client = new Silon({ apiKey: "sk_live_...", baseUrl: "https://silon.customer.internal" });
472
+ ```
473
+
474
+ API keys, the error contract, retries, idempotency, and webhook signature
475
+ verification all behave identically — they ride on the base URL.
476
+
477
+ **Private CA / self-signed TLS.** The `fetch` option is the transport seam.
478
+ Node's built-in fetch is powered by [undici](https://undici.nodejs.org), so
479
+ bind a `fetch` to an undici `Agent` that trusts your CA:
480
+
481
+ ```ts
482
+ import { Agent, fetch as undiciFetch } from "undici";
483
+ import { readFileSync } from "node:fs";
484
+
485
+ const agent = new Agent({
486
+ connect: { ca: readFileSync("/etc/pki/customer-ca.pem") },
487
+ });
488
+
489
+ const client = new Silon({
490
+ apiKey: "sk_live_...",
491
+ baseUrl: "https://10.20.0.5",
492
+ fetch: (input, init) => undiciFetch(input, { ...init, dispatcher: agent }),
493
+ });
494
+ ```
495
+
496
+ (Alternatively, `NODE_EXTRA_CA_CERTS=/etc/pki/customer-ca.pem` extends the
497
+ default trust store process-wide with no code changes.)
498
+
499
+ The SDK's `timeoutMs` still applies with a custom `fetch` — each attempt is
500
+ passed an `AbortSignal.timeout(timeoutMs)` signal, so forward `init.signal`
501
+ if you wrap the call.
502
+
503
+ **Reverse proxies.** Cursor pagination never follows the server's opaque
504
+ `next` URL directly — the SDK extracts only its query parameters and
505
+ re-issues the request against your configured `baseUrl`, so a proxy that
506
+ rewrites hostnames can't send pagination to an unreachable internal host.
507
+
508
+ ## Deprecated operations
509
+
510
+ `auth.login`, `bulk.send`, `push.listNotifications` and `push.subscribeWeb`
511
+ wrap legacy endpoints. They still work, but are tagged `@deprecated` (surfaced
512
+ by editors and TypeScript) and emit a one-time-per-process
513
+ `DeprecationWarning` via `process.emitWarning` when first called. Prefer
514
+ scoped `sk_live_` API keys over `auth.login`, `messages.sendBatch` (inline
515
+ `messages` rows or an uploaded CSV via `file`) over `bulk.send`, and the
516
+ widget over `push.subscribeWeb`. `bulk.files.upload` / `bulk.files.list` stay
517
+ non-deprecated — uploading is the ingestion path feeding `sendBatch`'s file
518
+ form.
519
+
520
+ ## Deviations from the cross-language SPEC (approved for Node)
521
+
522
+ - **Promise-based, async-only API** — the platform idiom (Python also ships a
523
+ sync client).
524
+ - **JSON field names stay `snake_case`** in params and response types
525
+ (stripe/openai-node convention); TypeScript interfaces mirror the wire
526
+ format, no camelCase transform.
527
+ - **Date-times stay ISO-8601 strings** (typed `string`), not `Date` objects.
528
+ - `idempotency_key` and `extra_body` are passed inside the params object
529
+ (mirroring Python's kwargs); both are SDK-level and never sent as JSON body
530
+ fields.
531
+ - Client-side validation failures (e.g. the `to`/`audience` XOR on
532
+ `messages.send`) throw `SilonError` — Node has no `ValueError`; likewise
533
+ `nextPage()` on the last page throws `SilonError` instead of an
534
+ illegal-state error type.
535
+ - Timeout is configured as `timeoutMs` (milliseconds), matching JS platform
536
+ convention; SPEC's 30 s default = `30000`.
537
+ - `bulk.files.upload` accepts raw content (`string`, `Uint8Array`/`Buffer`) or
538
+ a `{filename, content}` pair — not filesystem paths or streams (Python also
539
+ reads paths/file objects); read the file yourself first.
540
+ - `bulk.send` attachments are passed as `files` (the wire field name); Python
541
+ calls the same parameter `attachments`.
542
+ - Operation names are camelCase (`reports.awsUsage`, `reports.specificBulks`,
543
+ `push.subscribeAndroid`, `push.markRead`, …) per JS convention, while wire
544
+ field names in params/responses stay snake_case.
545
+ - **Response headers are not surfaced on results.** SPEC's optional
546
+ `Idempotent-Replayed` / `RateLimit-Limit`/`Remaining`/`Reset` surfacing is
547
+ a per-SDK "optional / at minimum" seam, and this SDK has no precedent for
548
+ exposing response metadata on results (resource methods return the decoded
549
+ body only, and `request()` returns the parsed JSON, not the `Response`). No
550
+ such seam was added. `RateLimit-Reset` is still consumed internally for
551
+ retry backoff, and `Retry-After` / `RateLimit-Reset` are parsed onto
552
+ `RateLimitError.retryAfter`.
553
+
554
+ ## Development
555
+
556
+ ```sh
557
+ npm install
558
+ npm test # vitest, fully offline (scripted fake fetch)
559
+ npm run build # tsc strict -> dist/ (ESM + .d.ts)
560
+ ```