broadcast-python 0.1.0__py3-none-any.whl
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.
- broadcast_python/__init__.py +66 -0
- broadcast_python/client.py +130 -0
- broadcast_python/configuration.py +117 -0
- broadcast_python/connection.py +384 -0
- broadcast_python/errors.py +76 -0
- broadcast_python/py.typed +0 -0
- broadcast_python/resources/__init__.py +0 -0
- broadcast_python/resources/autopilots.py +83 -0
- broadcast_python/resources/base.py +44 -0
- broadcast_python/resources/broadcasts.py +44 -0
- broadcast_python/resources/discovery.py +35 -0
- broadcast_python/resources/email_servers.py +68 -0
- broadcast_python/resources/migration.py +113 -0
- broadcast_python/resources/opt_in_forms.py +62 -0
- broadcast_python/resources/segments.py +23 -0
- broadcast_python/resources/sequences.py +58 -0
- broadcast_python/resources/subscribers.py +72 -0
- broadcast_python/resources/templates.py +34 -0
- broadcast_python/resources/transactionals.py +85 -0
- broadcast_python/resources/webhook_endpoints.py +29 -0
- broadcast_python/response.py +160 -0
- broadcast_python/version.py +3 -0
- broadcast_python/webhook.py +121 -0
- broadcast_python-0.1.0.dist-info/METADATA +488 -0
- broadcast_python-0.1.0.dist-info/RECORD +27 -0
- broadcast_python-0.1.0.dist-info/WHEEL +4 -0
- broadcast_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: broadcast-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Broadcast email platform. Subscribers, sequences, broadcasts, segments, templates, autopilot, webhooks, and transactional email.
|
|
5
|
+
Project-URL: Homepage, https://sendbroadcast.net
|
|
6
|
+
Project-URL: Repository, https://github.com/send-broadcast/broadcast-python
|
|
7
|
+
Project-URL: Changelog, https://github.com/send-broadcast/broadcast-python/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Simon Chiu
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: broadcast,email,email-marketing,newsletter,sendbroadcast,transactional
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Communications :: Email
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# broadcast-python
|
|
26
|
+
|
|
27
|
+
Official Python client for [Broadcast](https://sendbroadcast.net), the self-hosted email marketing platform.
|
|
28
|
+
|
|
29
|
+
Works with any Broadcast instance โ self-hosted or SaaS. Covers **104/104 API operations**, verified against the API's generated OpenAPI document.
|
|
30
|
+
|
|
31
|
+
๐ **[Python SDK documentation](https://sendbroadcast.net/docs/python-sdk)** ยท [API reference](https://sendbroadcast.net/docs/api-authentication) ยท [All docs](https://sendbroadcast.net/docs)
|
|
32
|
+
|
|
33
|
+
Also available: [Ruby](https://github.com/send-broadcast/broadcast-ruby) ยท [PHP](https://github.com/send-broadcast/broadcast-php) ยท [Node/TypeScript](https://github.com/send-broadcast/broadcast-node)
|
|
34
|
+
|
|
35
|
+
> **Not yet on PyPI.** The package is complete and tested but unpublished, so
|
|
36
|
+
> `pip install broadcast-python` will not resolve. Install from the repository
|
|
37
|
+
> until it lands:
|
|
38
|
+
>
|
|
39
|
+
> ```bash
|
|
40
|
+
> pip install git+https://github.com/send-broadcast/broadcast-python
|
|
41
|
+
> ```
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install broadcast-python
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Python 3.9+. **No runtime dependencies** โ the transport is built on the
|
|
50
|
+
standard library's `urllib`, so it cannot conflict with a pinned `requests` or
|
|
51
|
+
`httpx` elsewhere in your environment.
|
|
52
|
+
|
|
53
|
+
The distribution is `broadcast-python`; the module is `broadcast_python`. A
|
|
54
|
+
package named `broadcast` already exists on PyPI, so the module deliberately
|
|
55
|
+
does not claim that name.
|
|
56
|
+
|
|
57
|
+
## Getting Your API Token
|
|
58
|
+
|
|
59
|
+
1. Log in to your Broadcast dashboard
|
|
60
|
+
2. Go to **Settings > API Keys**
|
|
61
|
+
3. Click **New API Key**
|
|
62
|
+
4. Name it, select the permissions you need (see [Permissions](#api-token-permissions) below), and save
|
|
63
|
+
5. Copy the token
|
|
64
|
+
|
|
65
|
+
## Quick Start
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from broadcast_python import Broadcast
|
|
69
|
+
|
|
70
|
+
client = Broadcast(
|
|
71
|
+
api_token="...", # or BROADCAST_API_TOKEN
|
|
72
|
+
host="https://mail.example.com", # or BROADCAST_HOST โ required
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
client.subscribers.create(email="ada@example.com", first_name="Ada")
|
|
76
|
+
|
|
77
|
+
client.transactionals.create(
|
|
78
|
+
to="ada@example.com",
|
|
79
|
+
subject="Welcome",
|
|
80
|
+
body="<p>Glad you are here.</p>",
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### `host` is required
|
|
85
|
+
|
|
86
|
+
There is no default. Broadcast is self-hosted-first, so every instance lives at
|
|
87
|
+
its own domain and any built-in guess would be wrong for nearly everyone.
|
|
88
|
+
|
|
89
|
+
`BROADCAST_HOST` and `BROADCAST_API_TOKEN` are the same names the Broadcast CLI
|
|
90
|
+
uses in `~/.config/broadcast/config`, so a machine set up for the CLI needs no
|
|
91
|
+
extra configuration.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Configuration
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
Broadcast(
|
|
99
|
+
api_token="...",
|
|
100
|
+
host="https://...",
|
|
101
|
+
timeout=30, # read timeout, seconds
|
|
102
|
+
open_timeout=10,
|
|
103
|
+
retry_attempts=3,
|
|
104
|
+
retry_delay=1, # base backoff, multiplied by attempt number
|
|
105
|
+
max_retry_delay=30, # ceiling on a server-sent Retry-After
|
|
106
|
+
warnings_mode="log", # "log" | "raise" | "ignore"
|
|
107
|
+
logger=logging.getLogger(__name__),
|
|
108
|
+
debug=False,
|
|
109
|
+
broadcast_channel_id=42, # admin/system tokens
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A typo in a setting name raises `TypeError` rather than being silently ignored.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Responses
|
|
118
|
+
|
|
119
|
+
`Response` subclasses `dict`, so a result behaves exactly like the parsed body
|
|
120
|
+
while carrying transport metadata as attributes:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
result = client.subscribers.create(email="ada@example.com")
|
|
124
|
+
|
|
125
|
+
result["id"] # the body
|
|
126
|
+
result.status # 201
|
|
127
|
+
result.warnings # [Warning_(code=..., param=..., message=...)]
|
|
128
|
+
result.rate_limit.remaining
|
|
129
|
+
result.idempotent_replay # True if the API replayed a stored response
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Item access reads the body, attribute access reads metadata โ so a body field
|
|
133
|
+
named `status` (which broadcasts have) stays reachable as `result["status"]`.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Warnings
|
|
138
|
+
|
|
139
|
+
A 2xx response can carry warnings: the API accepted your request but ignored
|
|
140
|
+
part of it. A mistyped filter silently widens a result set unless you look.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
# "log" (default) โ warn through `logger`, return normally
|
|
144
|
+
# "raise" โ raise WarningError. NOTE: the write already happened.
|
|
145
|
+
# "ignore" โ say nothing; read them off result.warnings
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Rate Limits
|
|
151
|
+
|
|
152
|
+
Every response carries the current limit state, and 429s are retried
|
|
153
|
+
automatically, honouring the server's `Retry-After` (capped at `max_retry_delay`).
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
result = client.subscribers.list()
|
|
157
|
+
|
|
158
|
+
result.rate_limit.limit # 120
|
|
159
|
+
result.rate_limit.remaining # 118
|
|
160
|
+
result.rate_limit.reset # datetime
|
|
161
|
+
|
|
162
|
+
if result.rate_limit and result.rate_limit.remaining < 10:
|
|
163
|
+
time.sleep(1)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`rate_limit` is `None` when the server sends no limit headers, or when the limit
|
|
167
|
+
header cannot be parsed โ a value we cannot read makes the whole block
|
|
168
|
+
untrustworthy.
|
|
169
|
+
|
|
170
|
+
If the retries are exhausted you get a `RateLimitError`, which carries
|
|
171
|
+
`.retry_after` so you can requeue the job sensibly.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Errors
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
BroadcastError
|
|
179
|
+
โโโ ConfigurationError
|
|
180
|
+
โโโ APIError
|
|
181
|
+
โ โโโ AuthenticationError 401
|
|
182
|
+
โ โโโ AuthorizationError 403
|
|
183
|
+
โ โโโ NotFoundError 404
|
|
184
|
+
โ โโโ ConflictError 409 idempotency replay still in flight
|
|
185
|
+
โ โโโ RateLimitError 429 carries .retry_after
|
|
186
|
+
โโโ ValidationError 422
|
|
187
|
+
โโโ TimeoutError
|
|
188
|
+
โโโ DeliveryError
|
|
189
|
+
โโโ WarningError carries .warnings and .response
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`ValidationError` and `TimeoutError` are siblings of `APIError`, not children โ
|
|
193
|
+
matching the Ruby gem. Note `broadcast_python.TimeoutError` is **not** the builtin
|
|
194
|
+
`TimeoutError`.
|
|
195
|
+
|
|
196
|
+
Timeouts, 429s, and 5xx are retried with backoff. A 422 is not: it is
|
|
197
|
+
deterministic, so retrying is pure latency.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Common Tasks
|
|
202
|
+
|
|
203
|
+
### Subscribers
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
client.subscribers.list(page=1, is_active=True, tags=["vip"])
|
|
207
|
+
client.subscribers.find("ada@example.com")
|
|
208
|
+
client.subscribers.create(email="ada@example.com", tags=["vip"])
|
|
209
|
+
client.subscribers.update("ada@example.com", first_name="Ada")
|
|
210
|
+
client.subscribers.add_tags("ada@example.com", ["beta"])
|
|
211
|
+
client.subscribers.remove_tags("ada@example.com", ["beta"])
|
|
212
|
+
client.subscribers.activate("ada@example.com")
|
|
213
|
+
client.subscribers.deactivate("ada@example.com")
|
|
214
|
+
client.subscribers.unsubscribe("ada@example.com")
|
|
215
|
+
client.subscribers.resubscribe("ada@example.com")
|
|
216
|
+
client.subscribers.redact("ada@example.com") # irreversible
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`created_after` / `created_before` that fail to parse are **ignored** by the
|
|
220
|
+
server rather than rejected โ they return a `parameter_ignored` warning, so a
|
|
221
|
+
bad timestamp silently widens your result set. Check `result.warnings`.
|
|
222
|
+
|
|
223
|
+
### Broadcasts
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
client.broadcasts.create(subject="Weekly", body="<p>Hi</p>")
|
|
227
|
+
client.broadcasts.send(id) # no undo
|
|
228
|
+
client.broadcasts.schedule(id, scheduled_send_at="2026-08-01T09:00:00Z", scheduled_timezone="UTC")
|
|
229
|
+
client.broadcasts.cancel_schedule(id)
|
|
230
|
+
client.broadcasts.statistics(id)
|
|
231
|
+
client.broadcasts.statistics_timeline(id)
|
|
232
|
+
client.broadcasts.statistics_links(id)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Sequences
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
client.sequences.get(id, include_steps=True)
|
|
239
|
+
client.sequences.add_subscriber(id, email="ada@example.com")
|
|
240
|
+
client.sequences.remove_subscriber(id, "ada@example.com")
|
|
241
|
+
client.sequences.create_step(id, subject="Day 1")
|
|
242
|
+
client.sequences.move_step(id, step_id, under_id)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Segments, templates, opt-in forms
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
client.segments.create(name="VIPs")
|
|
249
|
+
client.templates.create(label="Welcome", subject="Hi", body="...")
|
|
250
|
+
client.opt_in_forms.analytics(id, start_date=date(2026, 1, 1))
|
|
251
|
+
client.opt_in_forms.create_variant(id, name="B", weight=50)
|
|
252
|
+
client.opt_in_forms.duplicate(id, label="Copy")
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Reading a segment recounts its members server-side, so `segments.get` is not free.
|
|
256
|
+
|
|
257
|
+
### Email servers
|
|
258
|
+
|
|
259
|
+
**Credential redaction guard.** The API returns credentials bullet-masked
|
|
260
|
+
(`โขโขโขโขโขโขโขโข`). A naive fetch-modify-save would write those bullets back and
|
|
261
|
+
destroy a working SMTP password. `update()` strips any credential field whose
|
|
262
|
+
value matches the redaction pattern and warns:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
server = client.email_servers.get(id)
|
|
266
|
+
server["smtp_password"] # 'โขโขโขโขโขโขโขโข'
|
|
267
|
+
client.email_servers.update(id, name="Renamed", smtp_password=server["smtp_password"])
|
|
268
|
+
# -> sends only {"name": "Renamed"}, warns about the dropped field
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Autopilot
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
client.autopilots.create(name="Weekly", ai_model="openai/gpt-4o")
|
|
275
|
+
client.autopilots.activate(id)
|
|
276
|
+
client.autopilots.trigger_run(id) # 202 โ async, poll runs()
|
|
277
|
+
client.autopilots.runs(id, limit=10)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`activate` requires an active source, an API key, and a model. Sources and tone
|
|
281
|
+
samples have **no API endpoints** โ they live in the web UI, so an autopilot
|
|
282
|
+
created entirely over the API cannot be activated until a source is added there.
|
|
283
|
+
|
|
284
|
+
### Transactional email
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
client.transactionals.create(
|
|
288
|
+
to="ada@example.com",
|
|
289
|
+
subject="Receipt",
|
|
290
|
+
body="<p>Thanks</p>",
|
|
291
|
+
idempotency_key="receipt-{}".format(order.id),
|
|
292
|
+
)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The server stores the response for 24 hours keyed on (token, key) and replays it
|
|
296
|
+
rather than sending a second email. The key is part of a fingerprint over
|
|
297
|
+
method + path + body:
|
|
298
|
+
|
|
299
|
+
- same key, same payload, still running โ `ConflictError` (409)
|
|
300
|
+
- same key, **different** payload โ `ValidationError` (422)
|
|
301
|
+
|
|
302
|
+
That 422 means "this key was already used for something else", not that the
|
|
303
|
+
email was invalid. Do not retry it with the same key.
|
|
304
|
+
|
|
305
|
+
This is the only endpoint that accepts `Idempotency-Key`.
|
|
306
|
+
|
|
307
|
+
### Discovery
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
client.whoami() # token identity and permissions
|
|
311
|
+
client.status() # channel readiness โ check before a send
|
|
312
|
+
client.prime() # full capability manifest
|
|
313
|
+
client.skill() # plain-text agent skill manifest (a str)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Migration / export
|
|
317
|
+
|
|
318
|
+
Read-only. **Admin tokens only**, and every call needs a `broadcast_channel_id`.
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
client = Broadcast(..., broadcast_channel_id=42)
|
|
322
|
+
|
|
323
|
+
client.migration.manifest()
|
|
324
|
+
|
|
325
|
+
for sub in client.migration.each_record("subscribers"):
|
|
326
|
+
... # auto-pages; advances by the limit the server actually applied
|
|
327
|
+
|
|
328
|
+
data = client.migration.download_file_asset(id) # bytes
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
On a demo instance this entire API returns **403 for every request**, valid
|
|
332
|
+
token or not โ deliberately, so a public demo cannot be used as a token oracle.
|
|
333
|
+
It surfaces as `AuthorizationError`.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## Channel Scoping
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
with client.with_channel(123):
|
|
341
|
+
client.email_servers.list() # scoped to channel 123
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The previous scope is restored on exit, including when the block raises. The
|
|
345
|
+
override lives on the client instance, so concurrent use across threads will
|
|
346
|
+
interleave โ use one client per thread, or pass `broadcast_channel_id`
|
|
347
|
+
explicitly.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Webhooks
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
from broadcast_python import webhook
|
|
355
|
+
|
|
356
|
+
valid = webhook.verify(
|
|
357
|
+
raw_body, # the raw bytes, not a re-serialised dict
|
|
358
|
+
request.headers["X-Broadcast-Signature"],
|
|
359
|
+
request.headers["X-Broadcast-Timestamp"],
|
|
360
|
+
os.environ["WEBHOOK_SECRET"],
|
|
361
|
+
)
|
|
362
|
+
if not valid:
|
|
363
|
+
return HttpResponse(status=401)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
HMAC-SHA256 over `timestamp.payload`, `v1,<base64>` header format, 5-minute
|
|
367
|
+
timestamp tolerance, constant-time comparison. `verify` returns `False` for
|
|
368
|
+
every rejection rather than distinguishing them.
|
|
369
|
+
|
|
370
|
+
Pass the **raw** request body. Re-serialising a parsed dict changes the bytes
|
|
371
|
+
and verification will fail.
|
|
372
|
+
|
|
373
|
+
`broadcast_python.EVENT_TYPES` lists all 32 event names.
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## API Token Permissions
|
|
378
|
+
|
|
379
|
+
Each token can be scoped to specific resources. Use the minimum permissions your
|
|
380
|
+
integration requires.
|
|
381
|
+
|
|
382
|
+
| Resource | Read permission | Write permission |
|
|
383
|
+
|----------|----------------|------------------|
|
|
384
|
+
| Transactional Emails | `transactionals_read` -- get delivery status | `transactionals_write` -- send emails |
|
|
385
|
+
| Subscribers | `subscribers_read` -- list, find | `subscribers_write` -- create, update, tag, deactivate, unsubscribe, redact |
|
|
386
|
+
| Sequences | `sequences_read` -- list, get, list steps | `sequences_write` -- create, update, delete, manage steps, enroll subscribers |
|
|
387
|
+
| Broadcasts | `broadcasts_read` -- list, get, statistics | `broadcasts_write` -- create, update, delete, send, schedule |
|
|
388
|
+
| Segments | `segments_read` -- list, get | `segments_write` -- create, update, delete |
|
|
389
|
+
| Templates | `templates_read` -- list, get | `templates_write` -- create, update, delete |
|
|
390
|
+
| Opt-In Forms | `opt_in_forms_read` -- list, get, analytics | `opt_in_forms_write` -- create, update, delete, create_variant, duplicate |
|
|
391
|
+
| Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
|
|
392
|
+
| Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
|
|
393
|
+
| Autopilot | `autopilot_read` -- list, get, runs | `autopilot_write` -- create, update, delete, activate, pause, deactivate, trigger_run |
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## Troubleshooting
|
|
398
|
+
|
|
399
|
+
### `AuthenticationError` (401)
|
|
400
|
+
|
|
401
|
+
- **Check the token:** it must be an API key from **Settings > API Keys**, not a
|
|
402
|
+
password or a session cookie.
|
|
403
|
+
- **Check the host:** pointing at the wrong instance produces a valid-looking
|
|
404
|
+
401, because the token is unknown there.
|
|
405
|
+
|
|
406
|
+
### `AuthorizationError` (403)
|
|
407
|
+
|
|
408
|
+
The token is valid but lacks the permission for that call. Check the table
|
|
409
|
+
above, then re-issue the key with the resource enabled โ permissions are fixed
|
|
410
|
+
at creation.
|
|
411
|
+
|
|
412
|
+
On a demo instance, the entire migration API returns 403 for every request,
|
|
413
|
+
valid token or not.
|
|
414
|
+
|
|
415
|
+
### `ValidationError` (422) on a repeated send
|
|
416
|
+
|
|
417
|
+
If you reused an `idempotency_key` with a *different* payload, the API rejects
|
|
418
|
+
it: the key is fingerprinted over method, path and body. It means "this key was
|
|
419
|
+
already used for something else", not that the email was invalid. Use a new key.
|
|
420
|
+
|
|
421
|
+
### Emails accepted but never delivered
|
|
422
|
+
|
|
423
|
+
Call `client.status()`. If `readiness["transactionals"]` is false, the channel
|
|
424
|
+
has no usable email server or sender identity โ the API accepts the request and
|
|
425
|
+
the send stalls. On a demo instance, sends are always accepted and never
|
|
426
|
+
delivered.
|
|
427
|
+
|
|
428
|
+
### `APIError` mentioning a redirect
|
|
429
|
+
|
|
430
|
+
Your `host` is wrong โ usually `http` instead of `https`, or a bare apex that
|
|
431
|
+
redirects to `www`. The client refuses to follow redirects on writes, and never
|
|
432
|
+
across hosts, because every request carries your API token. Set `host` to the
|
|
433
|
+
final URL.
|
|
434
|
+
|
|
435
|
+
### `TimeoutError` is not the builtin
|
|
436
|
+
|
|
437
|
+
`broadcast_python.TimeoutError` mirrors the Ruby gem's error hierarchy and is a
|
|
438
|
+
distinct class from Python's builtin. `except TimeoutError` in a module that
|
|
439
|
+
imported ours will not catch a socket timeout, and vice versa.
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Development
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
python -m unittest discover -s tests # mocked HTTP, no network
|
|
447
|
+
|
|
448
|
+
pip install ruff mypy
|
|
449
|
+
ruff check .
|
|
450
|
+
mypy
|
|
451
|
+
|
|
452
|
+
BROADCAST_LIVE_TEST=1 BROADCAST_HOST=http://localhost:3000 \
|
|
453
|
+
BROADCAST_API_TOKEN=... python -m unittest tests.test_live
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
The suite uses only the standard library โ no pytest required, and no install
|
|
457
|
+
step: `PYTHONPATH=src:tests` is the whole setup.
|
|
458
|
+
|
|
459
|
+
CI runs the suite on Python 3.9 through 3.14, lints with ruff, type-checks with
|
|
460
|
+
mypy, and separately builds the wheel and installs it into a clean virtualenv to
|
|
461
|
+
prove the packaged artifact imports and works.
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## Documentation
|
|
466
|
+
|
|
467
|
+
- **[Python SDK guide](https://sendbroadcast.net/docs/python-sdk)** โ the same material as this README, on the docs site
|
|
468
|
+
- **[API reference](https://sendbroadcast.net/docs/api-authentication)** โ endpoints, parameters, and permissions
|
|
469
|
+
- **[API response warnings](https://sendbroadcast.net/docs/api-response-warnings)** โ why a 2xx can still tell you something went wrong
|
|
470
|
+
- **[Webhook endpoints](https://sendbroadcast.net/docs/api-webhook-endpoints)** โ signature format and event types
|
|
471
|
+
- **[Agents CLI](https://sendbroadcast.net/docs/agents-cli)** โ the same credentials, from a terminal
|
|
472
|
+
|
|
473
|
+
### Other SDKs
|
|
474
|
+
|
|
475
|
+
| Language | Package | Repository |
|
|
476
|
+
|---|---|---|
|
|
477
|
+
| Python | broadcast-python | this repository |
|
|
478
|
+
| Ruby | [broadcast-ruby](https://rubygems.org/gems/broadcast-ruby) | [broadcast-ruby](https://github.com/send-broadcast/broadcast-ruby) |
|
|
479
|
+
| PHP | [broadcast/broadcast-php](https://packagist.org/packages/broadcast/broadcast-php) | [broadcast-php](https://github.com/send-broadcast/broadcast-php) |
|
|
480
|
+
| Node / TypeScript | [@send-broadcast/sdk](https://www.npmjs.com/package/@send-broadcast/sdk) | [broadcast-node](https://github.com/send-broadcast/broadcast-node) |
|
|
481
|
+
|
|
482
|
+
All four cover the same 104 operations and behave the same way on the wire โ the transport contract (warnings, idempotency, rate-limit handling, redirect safety, credential redaction) is identical across languages.
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## License
|
|
487
|
+
|
|
488
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
broadcast_python/__init__.py,sha256=_mzCFewxAEutE4fJ7hRPpaHuQwXpliHt5BNRE-J80TY,1632
|
|
2
|
+
broadcast_python/client.py,sha256=LNlD_iFiaWsVigOJTfjgJrm5nKY5rV9KrMZZK2EsyNM,4587
|
|
3
|
+
broadcast_python/configuration.py,sha256=kXheoYhwRJo6STIL9Wmhl_MOEdWfaV3CBETEh1SbEcI,3961
|
|
4
|
+
broadcast_python/connection.py,sha256=cAXyfwJ1pdleLm7S9fr1FUk5nqkdOTdav6dGZGJ0GfU,14064
|
|
5
|
+
broadcast_python/errors.py,sha256=NFJ69Oy_FtYZ3-BEq8Q1m5wLqcufRZLPDYuJlgBfGpc,1993
|
|
6
|
+
broadcast_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
broadcast_python/response.py,sha256=fIBzxi4a2Sc7sNSOb2_t85Y3Opdet89uNbHM5CX3YXw,5511
|
|
8
|
+
broadcast_python/version.py,sha256=WmWlkCJx3Rc5Kvlunmbdy7yVhhrpLaWiUyOhAKeVEnQ,169
|
|
9
|
+
broadcast_python/webhook.py,sha256=_6_QC0p6beNmw5FBMcD_ZBhLhDMtEGfsIqp_JfnybtM,3540
|
|
10
|
+
broadcast_python/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
broadcast_python/resources/autopilots.py,sha256=yDXs7lPi2tqJwPEi_e156jRFQIkVamsdvYGiHax_7HM,3416
|
|
12
|
+
broadcast_python/resources/base.py,sha256=MCnzyu4oNdMe0a9sccQIG_en017EAVk2hx31R8-Whvg,1493
|
|
13
|
+
broadcast_python/resources/broadcasts.py,sha256=VuYkKJ3d3mcaShXciMqAPHH-OT8px559VeXNltqkx3A,1954
|
|
14
|
+
broadcast_python/resources/discovery.py,sha256=2_TOfuPKAmQc63AGzBp61qhVBmCJdIYxeV5GlcS7YYs,1347
|
|
15
|
+
broadcast_python/resources/email_servers.py,sha256=1_ZJEPwVjFi5rN6S_0n7WfFfmF5y3JDIcW0Ixak0Z24,2830
|
|
16
|
+
broadcast_python/resources/migration.py,sha256=y-GqY4BWzdNnlc6TWu7wNmqcqzEWSroxva1KiMqAJQg,4099
|
|
17
|
+
broadcast_python/resources/opt_in_forms.py,sha256=ScoEKXejT27UfTL8iGxlsADO6g5Q-4xCpWOFtmM6q2E,2509
|
|
18
|
+
broadcast_python/resources/segments.py,sha256=ZfUJxiWPhERWzkzuRGVu09uWqbwyUbdr3NsKOEeuW8o,857
|
|
19
|
+
broadcast_python/resources/sequences.py,sha256=9IuKw13e_LYZMLZiBYrr0b-BPhAAAucdt8DUjQvbP80,2467
|
|
20
|
+
broadcast_python/resources/subscribers.py,sha256=DtUnHs1rj3LF7KACQRQFB-ofgAHl1WTiLluL4W7xbfw,3168
|
|
21
|
+
broadcast_python/resources/templates.py,sha256=FP0KcP61KkfrRID1XeAQ8hRxxRyxBzt404nXmnBqXNY,1262
|
|
22
|
+
broadcast_python/resources/transactionals.py,sha256=iwe9aTHKpeEMtDxd_S2TVzdUVm5Phno-Vinrxu9bn6w,2940
|
|
23
|
+
broadcast_python/resources/webhook_endpoints.py,sha256=Llkry10QT3JavkmKok3IAayYKM1PD1-iI51YhW3RbpE,1180
|
|
24
|
+
broadcast_python-0.1.0.dist-info/METADATA,sha256=dRJ8wybrA98MkCFbiA91AmnHtXA3-2L1vleZ19-t2bY,17580
|
|
25
|
+
broadcast_python-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
26
|
+
broadcast_python-0.1.0.dist-info/licenses/LICENSE,sha256=cnoPKJuOFIyR8mBe7kmEcUQelniJaujjGl21QWqzlKE,1067
|
|
27
|
+
broadcast_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simon Chiu
|
|
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.
|