silon-sdk 0.2.0__tar.gz

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 (67) hide show
  1. silon_sdk-0.2.0/.github/workflows/publish.yml +42 -0
  2. silon_sdk-0.2.0/.gitignore +8 -0
  3. silon_sdk-0.2.0/CHANGELOG.md +31 -0
  4. silon_sdk-0.2.0/LICENSE +21 -0
  5. silon_sdk-0.2.0/PKG-INFO +563 -0
  6. silon_sdk-0.2.0/README.md +536 -0
  7. silon_sdk-0.2.0/pyproject.toml +64 -0
  8. silon_sdk-0.2.0/src/silon/__init__.py +63 -0
  9. silon_sdk-0.2.0/src/silon/_client.py +424 -0
  10. silon_sdk-0.2.0/src/silon/_exceptions.py +228 -0
  11. silon_sdk-0.2.0/src/silon/_models.py +14 -0
  12. silon_sdk-0.2.0/src/silon/_pagination.py +117 -0
  13. silon_sdk-0.2.0/src/silon/_utils.py +52 -0
  14. silon_sdk-0.2.0/src/silon/_version.py +1 -0
  15. silon_sdk-0.2.0/src/silon/py.typed +0 -0
  16. silon_sdk-0.2.0/src/silon/resources/__init__.py +46 -0
  17. silon_sdk-0.2.0/src/silon/resources/_base.py +16 -0
  18. silon_sdk-0.2.0/src/silon/resources/account.py +207 -0
  19. silon_sdk-0.2.0/src/silon/resources/broadcasts.py +218 -0
  20. silon_sdk-0.2.0/src/silon/resources/bulk.py +301 -0
  21. silon_sdk-0.2.0/src/silon/resources/crm.py +405 -0
  22. silon_sdk-0.2.0/src/silon/resources/events.py +50 -0
  23. silon_sdk-0.2.0/src/silon/resources/messages.py +399 -0
  24. silon_sdk-0.2.0/src/silon/resources/otp.py +62 -0
  25. silon_sdk-0.2.0/src/silon/resources/push.py +159 -0
  26. silon_sdk-0.2.0/src/silon/resources/reports.py +288 -0
  27. silon_sdk-0.2.0/src/silon/resources/suppressions.py +128 -0
  28. silon_sdk-0.2.0/src/silon/resources/templates.py +185 -0
  29. silon_sdk-0.2.0/src/silon/resources/webhook_endpoints.py +187 -0
  30. silon_sdk-0.2.0/src/silon/resources/whatsapp_templates.py +45 -0
  31. silon_sdk-0.2.0/src/silon/types/__init__.py +94 -0
  32. silon_sdk-0.2.0/src/silon/types/_shared.py +3 -0
  33. silon_sdk-0.2.0/src/silon/types/account.py +29 -0
  34. silon_sdk-0.2.0/src/silon/types/broadcasts.py +79 -0
  35. silon_sdk-0.2.0/src/silon/types/bulk.py +113 -0
  36. silon_sdk-0.2.0/src/silon/types/crm.py +34 -0
  37. silon_sdk-0.2.0/src/silon/types/events.py +59 -0
  38. silon_sdk-0.2.0/src/silon/types/messages.py +217 -0
  39. silon_sdk-0.2.0/src/silon/types/otp.py +42 -0
  40. silon_sdk-0.2.0/src/silon/types/push.py +49 -0
  41. silon_sdk-0.2.0/src/silon/types/reports.py +29 -0
  42. silon_sdk-0.2.0/src/silon/types/suppressions.py +36 -0
  43. silon_sdk-0.2.0/src/silon/types/templates.py +57 -0
  44. silon_sdk-0.2.0/src/silon/types/webhook_endpoints.py +106 -0
  45. silon_sdk-0.2.0/src/silon/types/whatsapp_templates.py +51 -0
  46. silon_sdk-0.2.0/src/silon/webhooks.py +104 -0
  47. silon_sdk-0.2.0/tests/__init__.py +0 -0
  48. silon_sdk-0.2.0/tests/conftest.py +20 -0
  49. silon_sdk-0.2.0/tests/test_account.py +78 -0
  50. silon_sdk-0.2.0/tests/test_broadcasts_events.py +345 -0
  51. silon_sdk-0.2.0/tests/test_bulk.py +200 -0
  52. silon_sdk-0.2.0/tests/test_client.py +104 -0
  53. silon_sdk-0.2.0/tests/test_crm.py +194 -0
  54. silon_sdk-0.2.0/tests/test_errors.py +237 -0
  55. silon_sdk-0.2.0/tests/test_livemode.py +184 -0
  56. silon_sdk-0.2.0/tests/test_messages.py +697 -0
  57. silon_sdk-0.2.0/tests/test_otp.py +76 -0
  58. silon_sdk-0.2.0/tests/test_pagination.py +145 -0
  59. silon_sdk-0.2.0/tests/test_push.py +92 -0
  60. silon_sdk-0.2.0/tests/test_reports.py +78 -0
  61. silon_sdk-0.2.0/tests/test_retries.py +161 -0
  62. silon_sdk-0.2.0/tests/test_suppressions.py +411 -0
  63. silon_sdk-0.2.0/tests/test_templates.py +165 -0
  64. silon_sdk-0.2.0/tests/test_webhook_endpoints.py +211 -0
  65. silon_sdk-0.2.0/tests/test_webhooks_signature.py +84 -0
  66. silon_sdk-0.2.0/tests/test_whatsapp_templates.py +50 -0
  67. silon_sdk-0.2.0/uv.lock +453 -0
@@ -0,0 +1,42 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ['v*']
7
+ pull_request:
8
+ branches: [main]
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v6
17
+ - name: Set up Python
18
+ run: uv python install 3.12
19
+ - name: Run test suite
20
+ run: uv run pytest
21
+
22
+ publish:
23
+ needs: build
24
+ if: startsWith(github.ref, 'refs/tags/v')
25
+ runs-on: ubuntu-latest
26
+ environment: pypi
27
+ permissions:
28
+ id-token: write # required for GitHub OIDC → PyPI trusted publishing
29
+ contents: read
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: actions/setup-python@v5
33
+ with:
34
+ python-version: 3.12
35
+
36
+ - name: Build sdist + wheel
37
+ run: |
38
+ python -m pip install --upgrade build
39
+ python -m build
40
+
41
+ - name: Publish to PyPI (Trusted Publishing / OIDC)
42
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ ### CRM lists are now cursor-paginated (C2 additive grammar)
6
+
7
+ `client.clients` and `client.client_groups` now target the canonical **plural**
8
+ CRM routes — `/api/v1/crm/clients/` and `/api/v1/crm/groups/` — for every
9
+ operation (`list`, `create`, `retrieve`, `update`, `replace`, `delete`). The
10
+ deprecated singular `/crm/client/` + `/crm/group/` routes are unchanged on the
11
+ server and remain reachable, but the SDK no longer uses them.
12
+
13
+ - **`clients.list()` / `client_groups.list()` return a cursor page**
14
+ (`SyncCursorPage` / `AsyncCursorPage`) instead of a bare `list`, matching the
15
+ existing `events` / `templates` / `suppressions` idiom. They accept `cursor`
16
+ and `limit`.
17
+ - **Backward compatible for existing call sites.** The cursor page is iterable,
18
+ indexable, and sized, so code written against the old bare-list return —
19
+ `for c in client.clients.list()`, `client.clients.list()[0]`,
20
+ `len(client.clients.list())` — keeps working unchanged. No public
21
+ method/accessor was removed or renamed.
22
+ - **Minor behavior change:** a single `list()` call now yields **one page**
23
+ (default 50, max 100) rather than every contact/group at once. Call
24
+ `.auto_paging_iter()` to walk all pages lazily.
25
+
26
+ Single-object shapes (`ClientProfile`, `ClientGroup`) and the create/update/
27
+ replace/delete request bodies are unchanged.
28
+
29
+ ## 0.1.0
30
+
31
+ Initial release.
@@ -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.
@@ -0,0 +1,563 @@
1
+ Metadata-Version: 2.4
2
+ Name: silon-sdk
3
+ Version: 0.2.0
4
+ Summary: Python SDK for the Silon messaging platform API
5
+ Project-URL: Homepage, https://silon.tech
6
+ Project-URL: Documentation, https://silon.tech/docs/
7
+ Project-URL: Repository, https://github.com/KUWAITNET/silon-python-sdk
8
+ Author: Silon
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,messaging,otp,sdk,silon,sms,whatsapp
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Communications
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: pydantic>=2.7
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Silon Python SDK
29
+
30
+ [![PyPI version](https://img.shields.io/pypi/v/silon-sdk)](https://pypi.org/project/silon-sdk/)
31
+
32
+ Python client for the [Silon](https://silon.tech) messaging platform API — send
33
+ messages on any channel (WhatsApp, SMS, email, push, web push, voice), manage
34
+ CRM contacts and groups, run bulk campaigns, consume events, and verify
35
+ webhooks. Sync and async, fully typed.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install silon-sdk
41
+ ```
42
+
43
+ Requires Python 3.10+. Built on [httpx](https://www.python-httpx.org) and
44
+ [pydantic v2](https://docs.pydantic.dev).
45
+
46
+ ## Quickstart
47
+
48
+ ```python
49
+ from silon import Silon
50
+
51
+ client = Silon(
52
+ api_key="sk_live_...", # Settings → API keys; or set SILON_API_KEY
53
+ workspace="acme", # => https://acme.silon.tech; or set SILON_WORKSPACE / SILON_BASE_URL
54
+ )
55
+
56
+ sent = client.messages.send(
57
+ channel="whatsapp",
58
+ to={"client_id": "cust_001"},
59
+ content={"body": "Your order has shipped 📦"},
60
+ )
61
+ print(sent.id, sent.status) # e.g. "9f3e..." "queued"
62
+ ```
63
+
64
+ Async is a mirror image:
65
+
66
+ ```python
67
+ from silon import AsyncSilon
68
+
69
+ async with AsyncSilon(api_key="sk_live_...", workspace="acme") as client:
70
+ sent = await client.messages.send(
71
+ channel="sms",
72
+ to={"phone_number": "+96512345678"},
73
+ content={"body": "Your code is 424242"},
74
+ )
75
+ ```
76
+
77
+ ## Sending
78
+
79
+ One grammar, every channel. `messages.send` targets a single recipient with
80
+ `to`; `messages.send_batch` sends many independent personalised messages in
81
+ one call — up to 500 inline rows via `messages`, or an uploaded CSV of any
82
+ size via `file`; `broadcasts.create` fans one piece of content out to an
83
+ `audience` (`client_group`, `client_ids`, or an inline `recipients` list).
84
+
85
+ ```python
86
+ # Approved WhatsApp template to a raw number
87
+ client.messages.send(
88
+ channel="whatsapp",
89
+ to={"phone_number": "+12025550123"},
90
+ whatsapp_template={"name": "order_confirmed", "language": "en",
91
+ "variables": {"body_1": "Sara", "body_2": "ORD-42"}},
92
+ provider="meta_cloud",
93
+ )
94
+
95
+ # Email broadcast to a client group
96
+ result = client.broadcasts.create(
97
+ channel="email",
98
+ audience={"type": "client_group", "slug": "vip"},
99
+ content={"subject": "We saved you a seat", "body": "<h1>Hello</h1>"},
100
+ )
101
+ print(result.target_count, result.skipped_count)
102
+ if result.skipped: # per-reason breakdown; skipped_count is the sum
103
+ print(result.skipped.suppressed, result.skipped.wrong_channel,
104
+ result.skipped.duplicate)
105
+
106
+ # SMS broadcast to an inline ad-hoc list (max 1,000 rows; duplicates
107
+ # are deduped into skipped.duplicate)
108
+ client.broadcasts.create(
109
+ channel="sms",
110
+ audience={"type": "recipients", "recipients": [
111
+ {"phone_number": "+96550001234"},
112
+ {"phone_number": "+96550001235"},
113
+ {"client_id": "cust_001"},
114
+ ]},
115
+ content={"body": "Flash sale ends tonight"},
116
+ )
117
+
118
+ # Track it
119
+ broadcast = client.broadcasts.retrieve(result.id)
120
+ for delivery in client.broadcasts.deliveries(result.id).auto_paging_iter():
121
+ print(delivery.client_id, delivery.status)
122
+
123
+ # Personalised batch — every row its own recipient and content (max 500 rows;
124
+ # rows are the same shape as messages.send minus audience, and a row's
125
+ # channel= overrides the top-level default). Validation is all-or-nothing:
126
+ # one bad row 422s the batch with its index and nothing is queued.
127
+ batch = client.messages.send_batch(
128
+ channel="sms",
129
+ messages=[
130
+ {"to": {"phone_number": "+96550001234"},
131
+ "content": {"body": "Sara, your table for 2 is confirmed for 7pm."}},
132
+ {"channel": "email", "to": {"email": "omar@example.com"},
133
+ "content": {"subject": "Confirmed", "body": "<p>Table for 4 at 9pm.</p>"}},
134
+ ],
135
+ )
136
+ for row in batch.messages: # per-row envelopes, in request order
137
+ print(row.id, row.channel, row.status)
138
+ status = client.messages.retrieve(batch.messages[0].id) # each row pollable
139
+
140
+ # File batch — upload a CSV once, then run it through the same endpoint.
141
+ # Request-level fields act as row defaults; CSV columns override per row
142
+ # ({{variable}} columns render into the default content). Rows expand
143
+ # asynchronously, so the response is the aggregate batch object
144
+ # (batch.messages is None) and batch.id is the bulk batch id — read
145
+ # per-row status back via client.bulk.retrieve(batch.id) and the reports.
146
+ uploaded = client.bulk.files.upload("contacts.csv")
147
+ batch = client.messages.send_batch(
148
+ file=uploaded.name,
149
+ channel="sms",
150
+ content={"body": "Hello {{name}}, sale ends tonight."},
151
+ )
152
+ print(batch.id, batch.status, batch.row_count) # e.g. "17" "queued" 1200
153
+ ```
154
+
155
+ Every `messages.send` / `messages.send_batch` / `broadcasts.create` /
156
+ `otp.send` call carries an `Idempotency-Key` header (auto-generated UUID
157
+ unless you pass `idempotency_key=`), so automatic retries can never
158
+ double-send.
159
+
160
+ `messages.retrieve(id)` returns the status envelope — read the modern keys
161
+ `id` / `object` / `channel` / `status` and `timeline`, the ordered list of
162
+ attested `{status, at, provider?}` transitions (its vocabulary adds a
163
+ per-recipient `delivered`, which appears only in the timeline, never as the
164
+ top-level `status`):
165
+
166
+ ```python
167
+ status = client.messages.retrieve(sent.id)
168
+ print(status.status) # queued | sent | failed | ...
169
+ for entry in status.timeline:
170
+ print(entry.at, entry.status, entry.provider)
171
+ ```
172
+
173
+ The legacy `event_id` / `is_sent` / `messages` keys still decode but are
174
+ deprecated (accessing them warns) — prefer `id` / `timeline`.
175
+
176
+ ## Scheduling and cancellation
177
+
178
+ Pass `send_at=` to `messages.send`, `broadcasts.create`, or the file form of
179
+ `messages.send_batch` to schedule instead of dispatching immediately — an
180
+ aware `datetime` or an ISO-8601 string with a UTC offset (naive date-times
181
+ are rejected), strictly in the future and at most 90 days ahead (422
182
+ `send-at-invalid` otherwise). The envelope comes back with
183
+ `status="scheduled"` and its `id` stays stable through dispatch, so the same
184
+ id works with `retrieve` before and after the send actually runs:
185
+
186
+ ```python
187
+ from datetime import datetime, timezone
188
+
189
+ scheduled = client.messages.send(
190
+ channel="sms",
191
+ to={"phone_number": "+96550001234"},
192
+ content={"body": "Doors open in an hour!"},
193
+ send_at=datetime(2026, 8, 1, 9, 0, tzinfo=timezone.utc), # or an ISO-8601 string
194
+ )
195
+ print(scheduled.status) # "scheduled"
196
+
197
+ client.messages.retrieve(scheduled.id) # resolves while still scheduled
198
+
199
+ # Change of plans — allowed while status is still "scheduled":
200
+ canceled = client.messages.cancel(scheduled.id)
201
+ print(canceled.status) # "canceled" — it will never dispatch
202
+ ```
203
+
204
+ `broadcasts.cancel(id)` works the same way (on a scheduled broadcast,
205
+ `target_count` / `skipped_count` may be `None` until the audience resolves
206
+ at dispatch time). Cancel is idempotent by nature: repeating it on an
207
+ already-canceled send returns 200 with the canceled envelope again, so no
208
+ `Idempotency-Key` is sent. Canceling a send that already dispatched raises
209
+ `ConflictError` (409 `not-cancellable`); scheduled creates themselves stay
210
+ always-keyed like immediate ones.
211
+
212
+ `send_at` on the *inline* batch form is rejected with 422 `batch-invalid` —
213
+ there is no batch cancel resource by design; schedule those rows
214
+ individually via `messages.send`, or use the file form (rows expand and
215
+ send at dispatch time).
216
+
217
+ Scheduled sends add `scheduled` and `canceled` to the status vocabulary
218
+ (message: `scheduled|queued|sent|failed|canceled`, broadcast:
219
+ `scheduled|in_progress|completed|failed|canceled`), and cancels emit
220
+ `message.canceled` / `broadcast.canceled` events.
221
+
222
+ ## Suppressions
223
+
224
+ `client.suppressions` manages the workspace's do-not-contact list. A row
225
+ matches on (address, channel) or (address, all channels), and is enforced
226
+ on **every** send path:
227
+
228
+ - Single-recipient sends (`messages.send` with `to`, `otp.send`) to a
229
+ suppressed address raise `UnprocessableEntityError`
230
+ (422 `recipient-suppressed`).
231
+ - Fan-outs (broadcasts, batch rows — inline and CSV — and legacy bulk)
232
+ **skip** suppressed recipients instead, never an error: the envelope's
233
+ `skipped` breakdown itemises them (`suppressed` / `wrong_channel` /
234
+ `duplicate`, always all three keys) and `skipped_count` stays the sum.
235
+ Suppressed inline batch rows are omitted from `batch.messages`; the file
236
+ form reports its breakdown via `client.bulk.retrieve(batch.id)` once
237
+ async expansion runs.
238
+
239
+ ```python
240
+ # Suppress an address on every channel (reason defaults to "manual")
241
+ sup = client.suppressions.create(address="+96550001234")
242
+
243
+ # Or scope to one channel, with a reason
244
+ client.suppressions.create(
245
+ address="sara@example.com", channel="email", reason="unsubscribe",
246
+ )
247
+
248
+ # Create is idempotent by nature: re-adding the same (address, channel)
249
+ # answers 200 with the EXISTING row — never an error.
250
+
251
+ # List (cursor-paginated) with filters
252
+ for sup in client.suppressions.list(reason="stop").auto_paging_iter():
253
+ print(sup.address, sup.channel, sup.reason)
254
+
255
+ # Lift a suppression
256
+ client.suppressions.delete(sup.id)
257
+ ```
258
+
259
+ Addresses are stored normalized (compact E.164 / lowercase email), so any
260
+ formatting of the same address matches. Rows are mode-scoped: `sk_test_`
261
+ keys list, manage, and enforce **test** suppressions only, live keys live
262
+ ones. Listing needs the `suppressions:read` scope; create/delete need
263
+ `suppressions:write`.
264
+
265
+ For transactional/legal sends that must reach a suppressed recipient (a
266
+ receipt, a legally required notice), pass `override_suppression=True` to
267
+ `messages.send` — single-recipient sends only. The key must carry the
268
+ `suppressions:override` scope (in no preset; grant it explicitly),
269
+ otherwise the server answers 403 `missing-scope`. Overridden deliveries
270
+ are flagged `suppression_overridden` in the delivery ledger:
271
+
272
+ ```python
273
+ client.messages.send(
274
+ channel="email",
275
+ to={"email": "sara@example.com"}, # suppressed, but owed a receipt
276
+ content={"subject": "Your receipt", "body": "..."},
277
+ override_suppression=True,
278
+ )
279
+ ```
280
+
281
+ ## Templates
282
+
283
+ `client.templates` manages slug-keyed message templates — the content the
284
+ send pipeline renders for `template={"slug": ...}` sends — with an
285
+ **immutable version spine**. Every content edit (`subject` / `body` /
286
+ `body_md`) mints a new version; `channel` is metadata and never bumps the
287
+ version. An un-pinned send always renders the latest content; pin an older
288
+ revision with `template={"slug": ..., "version": N}`.
289
+
290
+ ```python
291
+ # Create at version 1
292
+ tmpl = client.templates.create(
293
+ slug="order-shipped",
294
+ channel="sms", # optional routing hint (metadata)
295
+ body_md="Hi {{ client_name }}, order {{ order_id }} has shipped.",
296
+ )
297
+
298
+ # Edit the body → mints version 2 (version 1 stays frozen and pinnable)
299
+ tmpl = client.templates.update(
300
+ "order-shipped", body_md="Hi {{ client_name }} — shipped today!",
301
+ )
302
+ print(tmpl.version, tmpl.versions) # 2 [1, 2]
303
+
304
+ # Render the latest on a send…
305
+ client.messages.send(
306
+ channel="sms", to={"client_id": "cust_001"},
307
+ template={"slug": "order-shipped", "variables": {"client_name": "Sara"}},
308
+ )
309
+ # …or pin an immutable revision (works on send / broadcasts.create / batch)
310
+ client.messages.send(
311
+ channel="sms", to={"client_id": "cust_001"},
312
+ template={"slug": "order-shipped", "version": 1,
313
+ "variables": {"client_name": "Sara", "order_id": "ORD-42"}},
314
+ )
315
+
316
+ # List (cursor-paginated; filter by channel hint or slug prefix)
317
+ for row in client.templates.list(q="order").auto_paging_iter():
318
+ print(row.slug, row.version)
319
+
320
+ # Archive (soft delete): reads as missing everywhere afterwards, but its
321
+ # version history survives and the slug stays reserved.
322
+ client.templates.delete("order-shipped")
323
+ ```
324
+
325
+ Listing/retrieving needs the `templates:read` scope; create/update/delete
326
+ need `templates:write`. An unknown or archived slug raises `NotFoundError`
327
+ (404 `template-not-found`); re-creating an archived slug raises
328
+ `ConflictError` (409 `template-exists`).
329
+
330
+ ## Resources
331
+
332
+ | Resource | Methods |
333
+ | --- | --- |
334
+ | `client.messages` | `send`, `send_batch`, `retrieve`, `cancel` |
335
+ | `client.broadcasts` | `create`, `retrieve`, `deliveries` (paginated), `cancel` |
336
+ | `client.otp` | `send`, `verify` |
337
+ | `client.clients` | `list` (paginated), `create`, `retrieve`, `update`, `replace`, `delete` |
338
+ | `client.client_groups` | `list` (paginated), `create`, `retrieve`, `update`, `replace`, `delete` |
339
+ | `client.bulk` | `list`, `retrieve`, `send` (deprecated — use `messages.send_batch`), `files.list`, `files.upload`, `recipients.retrieve` |
340
+ | `client.reports` | `messages`, `channels`, `clients`, `users`, `bulks`, `specific_bulks`, `subscriptions`, `aws_usage`, `balance` |
341
+ | `client.templates` | `list` (paginated), `create`, `retrieve`, `update`, `delete` |
342
+ | `client.whatsapp_templates` | `list`, `retrieve` |
343
+ | `client.webhook_endpoints` | `list` (paginated), `create`, `retrieve`, `update`, `delete`, `test`, `list_attempts` (paginated) |
344
+ | `client.events` | `list` (paginated), `retrieve` |
345
+ | `client.suppressions` | `list` (paginated), `create`, `delete` |
346
+ | `client.push` | `subscribe_android`, `subscribe_ios`, `upsert_devices`, `mark_read`, `list_notifications`, `subscribe_web` |
347
+ | `client.profile` | `retrieve`, `update`, `replace` |
348
+ | `client.auth` | `signup`, `login` (deprecated) |
349
+
350
+ ## Pagination
351
+
352
+ Cursor-paginated lists (`events`, `templates`, `webhook_endpoints`,
353
+ `webhook_endpoints.list_attempts`, `suppressions`, `broadcasts.deliveries`,
354
+ `clients`, `client_groups`) return a page you can walk manually or drain with
355
+ `auto_paging_iter()`:
356
+
357
+ ```python
358
+ page = client.events.list(type="message.failed", limit=100)
359
+ for event in page: # this page only
360
+ ...
361
+ for event in page.auto_paging_iter(): # every page, lazily
362
+ ...
363
+
364
+ # async
365
+ page = await client.events.list(limit=100)
366
+ async for event in page.auto_paging_iter():
367
+ ...
368
+ ```
369
+
370
+ > **Behavior change (0.2.0).** `client.clients.list()` and
371
+ > `client.client_groups.list()` now target the canonical plural CRM routes
372
+ > (`/crm/clients/`, `/crm/groups/`) and return a cursor **page** instead of a
373
+ > bare list. Existing `for c in client.clients.list()`, indexing, and `len()`
374
+ > call sites keep working unchanged (the page is iterable, indexable, and
375
+ > sized), but a single call now yields **one page** (default 50) rather than
376
+ > every contact — use `.auto_paging_iter()` to walk them all.
377
+
378
+ ## Errors
379
+
380
+ Non-2xx responses raise a typed exception with the parsed error payload:
381
+
382
+ ```python
383
+ import silon
384
+
385
+ try:
386
+ client.messages.send(channel="banana", to={"client_id": "x"})
387
+ except silon.UnprocessableEntityError as err: # 422
388
+ print(err.status_code, err.request_id)
389
+ for detail in err.errors:
390
+ print(detail.code, detail.attr, detail.detail)
391
+ except silon.RateLimitError as err: # 429
392
+ print("retry after", err.retry_after, "seconds")
393
+ ```
394
+
395
+ `BadRequestError` (400), `AuthenticationError` (401), `PermissionDeniedError`
396
+ (403), `NotFoundError` (404), `ConflictError` (409, idempotency-key reuse or
397
+ cancelling a send that already dispatched — `not-cancellable`),
398
+ `GoneError` (410, expired OTP), `UnprocessableEntityError` (422),
399
+ `RateLimitError` (429) and `InternalServerError` (5xx) all subclass
400
+ `APIStatusError`. Network failures raise `APIConnectionError` /
401
+ `APITimeoutError`.
402
+
403
+ Every v1 error carries a `retryable` flag, surfaced verbatim as
404
+ `err.retryable` (`True` for 429 / 5xx and an in-flight idempotency twin,
405
+ `False` for other 4xx). Branch on it instead of parsing status codes; it is
406
+ `None` only on a legacy/non-v1 body that omits the flag — never inferred
407
+ from the status code.
408
+
409
+ ```python
410
+ try:
411
+ client.messages.send(channel="sms", to={"phone_number": "+1"}, content={"body": "hi"})
412
+ except silon.APIStatusError as err:
413
+ if err.retryable:
414
+ ... # transient — safe to retry the same request
415
+ ```
416
+
417
+ Requests are retried automatically (default `max_retries=2`, exponential
418
+ backoff, honouring `Retry-After` / `RateLimit-Reset`) — but only when it is
419
+ safe: idempotent methods, plus POSTs that carry an `Idempotency-Key`.
420
+
421
+ ## Webhooks
422
+
423
+ Verify the `Silon-Signature` header on deliveries with the endpoint's
424
+ one-time `whsec_` secret:
425
+
426
+ ```python
427
+ from silon import webhooks
428
+
429
+ event = webhooks.construct_event(
430
+ payload=request.body, # raw bytes
431
+ header=request.headers["Silon-Signature"],
432
+ secret=os.environ["SILON_WEBHOOK_SECRET"],
433
+ )
434
+ if event.type == "broadcast.completed":
435
+ print(event.data.sent, "delivered,", event.data.failed, "failed")
436
+ ```
437
+
438
+ Send a signed `ping` to an endpoint to check it is reachable, and inspect
439
+ its delivery attempts (cursor-paginated). A failing sink is reported in-band
440
+ (`delivered=False` with the reason in `error`), never raised:
441
+
442
+ ```python
443
+ result = client.webhook_endpoints.test("we_01J1ABC")
444
+ print(result.delivered, result.response_status, result.latency_ms, result.error)
445
+
446
+ for attempt in client.webhook_endpoints.list_attempts("we_01J1ABC").auto_paging_iter():
447
+ print(attempt.event_type, attempt.attempts, attempt.ok, attempt.next_attempt_at)
448
+ ```
449
+
450
+ `test` needs the `webhooks:write` scope, `list_attempts` needs
451
+ `webhooks:read`. The endpoint id must match the key's mode (a live key tests
452
+ `livemode=True` endpoints, a test key `livemode=False`); test pings are never
453
+ persisted and never appear in `list_attempts`.
454
+
455
+ ## Test mode
456
+
457
+ An `sk_test_` API key exercises the full pipeline — validation, scopes,
458
+ throttles, idempotency, delivery rows, events, webhooks — but never
459
+ reaches a provider and never bills. Every affected envelope (message,
460
+ broadcast, batch, OTP, events, webhook payloads) carries
461
+ `livemode=False`; live traffic carries `livemode=True`.
462
+
463
+ ```python
464
+ client = Silon(api_key="sk_test_...", workspace="acme")
465
+
466
+ sent = client.messages.send(
467
+ channel="sms",
468
+ to={"phone_number": "+15005550001"}, # magic recipient: always delivered
469
+ content={"body": "test-mode ping"},
470
+ )
471
+ assert sent.livemode is False
472
+ ```
473
+
474
+ Magic recipients get deterministic outcomes. Statuses are simulated
475
+ asynchronously a few seconds after the 202, so polling and webhooks behave
476
+ realistically; any other recipient in test mode is delivered. In live mode
477
+ the magic values are rejected with 422 `test-recipient-in-live`, so test
478
+ fixtures can never leak into real sends.
479
+
480
+ | Recipient | Outcome |
481
+ | --- | --- |
482
+ | `+15005550001` | delivered |
483
+ | `+15005550002` | failed (simulated provider error) |
484
+ | `delivered@silon.test` | delivered |
485
+ | `bounce@silon.test` | failed |
486
+ | `+15005550009` | always suppressed (no suppression row needed) |
487
+ | `suppressed@silon.test` | always suppressed (no suppression row needed) |
488
+
489
+ The always-suppressed fixtures exercise the suppression path end to end: a
490
+ single send answers 422 `recipient-suppressed`, a fan-out skips them into
491
+ `skipped.suppressed` — exactly like a real suppression row, without
492
+ creating one.
493
+
494
+ Test-mode OTPs are never dispatched; the magic code `000000` always
495
+ verifies (and only it).
496
+
497
+ Webhook endpoints carry a create-time `livemode` flag (default `True`).
498
+ Test events deliver only to `livemode=False` endpoints, live events only
499
+ to `livemode=True` ones — register one endpoint per mode:
500
+
501
+ ```python
502
+ client.webhook_endpoints.create(
503
+ url="https://example.com/hooks/silon-test", livemode=False
504
+ )
505
+ ```
506
+
507
+ ## Configuration
508
+
509
+ | Argument | Env var | Default |
510
+ | --- | --- | --- |
511
+ | `api_key` | `SILON_API_KEY` | — (required) |
512
+ | `workspace` | `SILON_WORKSPACE` | — |
513
+ | `base_url` | `SILON_BASE_URL` | `https://<workspace>.silon.tech` |
514
+ | `timeout` | — | 30 s |
515
+ | `max_retries` | — | 2 |
516
+
517
+ A base URL must be resolvable at construction time, from one of four
518
+ sources checked in this order — otherwise the constructor raises
519
+ `SilonError` immediately:
520
+
521
+ 1. `base_url=` argument (wins over everything)
522
+ 2. `SILON_BASE_URL` env var
523
+ 3. `workspace=` argument → `https://<workspace>.silon.tech`
524
+ 4. `SILON_WORKSPACE` env var → same expansion
525
+
526
+ You can also pass `default_headers=` or your own `http_client=`
527
+ (`httpx.Client` / `httpx.AsyncClient`) for full transport control.
528
+
529
+ ## On-prem / self-hosted instances
530
+
531
+ The `workspace=` shortcut is SaaS-only sugar; everything else in the SDK is
532
+ host-agnostic. For a self-hosted Silon, point `base_url` at your instance:
533
+
534
+ ```python
535
+ client = Silon(api_key="sk_live_...", base_url="https://silon.customer.internal")
536
+ ```
537
+
538
+ API keys, the error contract, retries, idempotency, and webhook signature
539
+ verification all behave identically — they ride on the base URL.
540
+
541
+ **Private CA / self-signed TLS.** Supply your own transport:
542
+
543
+ ```python
544
+ http_client = httpx.Client(verify="/etc/pki/customer-ca.pem", timeout=30)
545
+ client = Silon(api_key="...", base_url="https://10.20.0.5", http_client=http_client)
546
+ ```
547
+
548
+ Note that a custom `http_client` brings its own timeout — set it on the
549
+ `httpx.Client`, since the SDK's `timeout=` only applies to the transport it
550
+ constructs itself.
551
+
552
+ **Reverse proxies.** Cursor pagination never follows the server's opaque
553
+ `next` URL directly — the SDK extracts only its query parameters and
554
+ re-issues the request against your configured `base_url`, so a proxy that
555
+ rewrites hostnames can't send pagination to an unreachable internal host.
556
+
557
+ ## Development
558
+
559
+ ```bash
560
+ uv sync
561
+ uv run pytest
562
+ uv run ruff check src tests
563
+ ```