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.
- silon_sdk-0.2.0/.github/workflows/publish.yml +42 -0
- silon_sdk-0.2.0/.gitignore +8 -0
- silon_sdk-0.2.0/CHANGELOG.md +31 -0
- silon_sdk-0.2.0/LICENSE +21 -0
- silon_sdk-0.2.0/PKG-INFO +563 -0
- silon_sdk-0.2.0/README.md +536 -0
- silon_sdk-0.2.0/pyproject.toml +64 -0
- silon_sdk-0.2.0/src/silon/__init__.py +63 -0
- silon_sdk-0.2.0/src/silon/_client.py +424 -0
- silon_sdk-0.2.0/src/silon/_exceptions.py +228 -0
- silon_sdk-0.2.0/src/silon/_models.py +14 -0
- silon_sdk-0.2.0/src/silon/_pagination.py +117 -0
- silon_sdk-0.2.0/src/silon/_utils.py +52 -0
- silon_sdk-0.2.0/src/silon/_version.py +1 -0
- silon_sdk-0.2.0/src/silon/py.typed +0 -0
- silon_sdk-0.2.0/src/silon/resources/__init__.py +46 -0
- silon_sdk-0.2.0/src/silon/resources/_base.py +16 -0
- silon_sdk-0.2.0/src/silon/resources/account.py +207 -0
- silon_sdk-0.2.0/src/silon/resources/broadcasts.py +218 -0
- silon_sdk-0.2.0/src/silon/resources/bulk.py +301 -0
- silon_sdk-0.2.0/src/silon/resources/crm.py +405 -0
- silon_sdk-0.2.0/src/silon/resources/events.py +50 -0
- silon_sdk-0.2.0/src/silon/resources/messages.py +399 -0
- silon_sdk-0.2.0/src/silon/resources/otp.py +62 -0
- silon_sdk-0.2.0/src/silon/resources/push.py +159 -0
- silon_sdk-0.2.0/src/silon/resources/reports.py +288 -0
- silon_sdk-0.2.0/src/silon/resources/suppressions.py +128 -0
- silon_sdk-0.2.0/src/silon/resources/templates.py +185 -0
- silon_sdk-0.2.0/src/silon/resources/webhook_endpoints.py +187 -0
- silon_sdk-0.2.0/src/silon/resources/whatsapp_templates.py +45 -0
- silon_sdk-0.2.0/src/silon/types/__init__.py +94 -0
- silon_sdk-0.2.0/src/silon/types/_shared.py +3 -0
- silon_sdk-0.2.0/src/silon/types/account.py +29 -0
- silon_sdk-0.2.0/src/silon/types/broadcasts.py +79 -0
- silon_sdk-0.2.0/src/silon/types/bulk.py +113 -0
- silon_sdk-0.2.0/src/silon/types/crm.py +34 -0
- silon_sdk-0.2.0/src/silon/types/events.py +59 -0
- silon_sdk-0.2.0/src/silon/types/messages.py +217 -0
- silon_sdk-0.2.0/src/silon/types/otp.py +42 -0
- silon_sdk-0.2.0/src/silon/types/push.py +49 -0
- silon_sdk-0.2.0/src/silon/types/reports.py +29 -0
- silon_sdk-0.2.0/src/silon/types/suppressions.py +36 -0
- silon_sdk-0.2.0/src/silon/types/templates.py +57 -0
- silon_sdk-0.2.0/src/silon/types/webhook_endpoints.py +106 -0
- silon_sdk-0.2.0/src/silon/types/whatsapp_templates.py +51 -0
- silon_sdk-0.2.0/src/silon/webhooks.py +104 -0
- silon_sdk-0.2.0/tests/__init__.py +0 -0
- silon_sdk-0.2.0/tests/conftest.py +20 -0
- silon_sdk-0.2.0/tests/test_account.py +78 -0
- silon_sdk-0.2.0/tests/test_broadcasts_events.py +345 -0
- silon_sdk-0.2.0/tests/test_bulk.py +200 -0
- silon_sdk-0.2.0/tests/test_client.py +104 -0
- silon_sdk-0.2.0/tests/test_crm.py +194 -0
- silon_sdk-0.2.0/tests/test_errors.py +237 -0
- silon_sdk-0.2.0/tests/test_livemode.py +184 -0
- silon_sdk-0.2.0/tests/test_messages.py +697 -0
- silon_sdk-0.2.0/tests/test_otp.py +76 -0
- silon_sdk-0.2.0/tests/test_pagination.py +145 -0
- silon_sdk-0.2.0/tests/test_push.py +92 -0
- silon_sdk-0.2.0/tests/test_reports.py +78 -0
- silon_sdk-0.2.0/tests/test_retries.py +161 -0
- silon_sdk-0.2.0/tests/test_suppressions.py +411 -0
- silon_sdk-0.2.0/tests/test_templates.py +165 -0
- silon_sdk-0.2.0/tests/test_webhook_endpoints.py +211 -0
- silon_sdk-0.2.0/tests/test_webhooks_signature.py +84 -0
- silon_sdk-0.2.0/tests/test_whatsapp_templates.py +50 -0
- 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,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.
|
silon_sdk-0.2.0/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.
|
silon_sdk-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](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
|
+
```
|