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.
- package/LICENSE +21 -0
- package/README.md +560 -0
- package/dist/client.d.ts +141 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +249 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +117 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +199 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/utils.d.ts +24 -0
- package/dist/internal/utils.d.ts.map +1 -0
- package/dist/internal/utils.js +58 -0
- package/dist/internal/utils.js.map +1 -0
- package/dist/pagination.d.ts +52 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +80 -0
- package/dist/pagination.js.map +1 -0
- package/dist/resources/auth.d.ts +20 -0
- package/dist/resources/auth.d.ts.map +1 -0
- package/dist/resources/auth.js +41 -0
- package/dist/resources/auth.js.map +1 -0
- package/dist/resources/broadcasts.d.ts +63 -0
- package/dist/resources/broadcasts.d.ts.map +1 -0
- package/dist/resources/broadcasts.js +103 -0
- package/dist/resources/broadcasts.js.map +1 -0
- package/dist/resources/bulk.d.ts +60 -0
- package/dist/resources/bulk.d.ts.map +1 -0
- package/dist/resources/bulk.js +137 -0
- package/dist/resources/bulk.js.map +1 -0
- package/dist/resources/client-groups.d.ts +50 -0
- package/dist/resources/client-groups.d.ts.map +1 -0
- package/dist/resources/client-groups.js +97 -0
- package/dist/resources/client-groups.js.map +1 -0
- package/dist/resources/clients.d.ts +51 -0
- package/dist/resources/clients.d.ts.map +1 -0
- package/dist/resources/clients.js +99 -0
- package/dist/resources/clients.js.map +1 -0
- package/dist/resources/events.d.ts +22 -0
- package/dist/resources/events.d.ts.map +1 -0
- package/dist/resources/events.js +38 -0
- package/dist/resources/events.js.map +1 -0
- package/dist/resources/messages.d.ts +99 -0
- package/dist/resources/messages.d.ts.map +1 -0
- package/dist/resources/messages.js +167 -0
- package/dist/resources/messages.js.map +1 -0
- package/dist/resources/otp.d.ts +26 -0
- package/dist/resources/otp.d.ts.map +1 -0
- package/dist/resources/otp.js +40 -0
- package/dist/resources/otp.js.map +1 -0
- package/dist/resources/profile.d.ts +18 -0
- package/dist/resources/profile.d.ts.map +1 -0
- package/dist/resources/profile.js +40 -0
- package/dist/resources/profile.js.map +1 -0
- package/dist/resources/push.d.ts +45 -0
- package/dist/resources/push.d.ts.map +1 -0
- package/dist/resources/push.js +103 -0
- package/dist/resources/push.js.map +1 -0
- package/dist/resources/reports.d.ts +44 -0
- package/dist/resources/reports.d.ts.map +1 -0
- package/dist/resources/reports.js +127 -0
- package/dist/resources/reports.js.map +1 -0
- package/dist/resources/suppressions.d.ts +54 -0
- package/dist/resources/suppressions.d.ts.map +1 -0
- package/dist/resources/suppressions.js +79 -0
- package/dist/resources/suppressions.js.map +1 -0
- package/dist/resources/templates.d.ts +58 -0
- package/dist/resources/templates.d.ts.map +1 -0
- package/dist/resources/templates.js +96 -0
- package/dist/resources/templates.js.map +1 -0
- package/dist/resources/webhook-endpoints.d.ts +54 -0
- package/dist/resources/webhook-endpoints.d.ts.map +1 -0
- package/dist/resources/webhook-endpoints.js +96 -0
- package/dist/resources/webhook-endpoints.js.map +1 -0
- package/dist/resources/whatsapp-templates.d.ts +20 -0
- package/dist/resources/whatsapp-templates.d.ts.map +1 -0
- package/dist/resources/whatsapp-templates.js +33 -0
- package/dist/resources/whatsapp-templates.js.map +1 -0
- package/dist/types/auth.d.ts +34 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +8 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/types/broadcasts.d.ts +174 -0
- package/dist/types/broadcasts.d.ts.map +1 -0
- package/dist/types/broadcasts.js +8 -0
- package/dist/types/broadcasts.js.map +1 -0
- package/dist/types/bulk.d.ts +161 -0
- package/dist/types/bulk.d.ts.map +1 -0
- package/dist/types/bulk.js +8 -0
- package/dist/types/bulk.js.map +1 -0
- package/dist/types/client-groups.d.ts +52 -0
- package/dist/types/client-groups.d.ts.map +1 -0
- package/dist/types/client-groups.js +8 -0
- package/dist/types/client-groups.js.map +1 -0
- package/dist/types/clients.d.ts +64 -0
- package/dist/types/clients.d.ts.map +1 -0
- package/dist/types/clients.js +8 -0
- package/dist/types/clients.js.map +1 -0
- package/dist/types/events.d.ts +64 -0
- package/dist/types/events.d.ts.map +1 -0
- package/dist/types/events.js +9 -0
- package/dist/types/events.js.map +1 -0
- package/dist/types/messages.d.ts +334 -0
- package/dist/types/messages.d.ts.map +1 -0
- package/dist/types/messages.js +8 -0
- package/dist/types/messages.js.map +1 -0
- package/dist/types/otp.d.ts +61 -0
- package/dist/types/otp.d.ts.map +1 -0
- package/dist/types/otp.js +8 -0
- package/dist/types/otp.js.map +1 -0
- package/dist/types/profile.d.ts +37 -0
- package/dist/types/profile.d.ts.map +1 -0
- package/dist/types/profile.js +8 -0
- package/dist/types/profile.js.map +1 -0
- package/dist/types/push.d.ts +90 -0
- package/dist/types/push.d.ts.map +1 -0
- package/dist/types/push.js +8 -0
- package/dist/types/push.js.map +1 -0
- package/dist/types/reports.d.ts +109 -0
- package/dist/types/reports.d.ts.map +1 -0
- package/dist/types/reports.js +8 -0
- package/dist/types/reports.js.map +1 -0
- package/dist/types/suppressions.d.ts +72 -0
- package/dist/types/suppressions.d.ts.map +1 -0
- package/dist/types/suppressions.js +8 -0
- package/dist/types/suppressions.js.map +1 -0
- package/dist/types/templates.d.ts +96 -0
- package/dist/types/templates.d.ts.map +1 -0
- package/dist/types/templates.js +9 -0
- package/dist/types/templates.js.map +1 -0
- package/dist/types/webhook-endpoints.d.ts +123 -0
- package/dist/types/webhook-endpoints.d.ts.map +1 -0
- package/dist/types/webhook-endpoints.js +8 -0
- package/dist/types/webhook-endpoints.js.map +1 -0
- package/dist/types/whatsapp-templates.d.ts +59 -0
- package/dist/types/whatsapp-templates.d.ts.map +1 -0
- package/dist/types/whatsapp-templates.js +8 -0
- package/dist/types/whatsapp-templates.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/webhooks.d.ts +48 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +97 -0
- package/dist/webhooks.js.map +1 -0
- 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
|
+
[](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
|
+
```
|