broadcast-python 0.1.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 (29) hide show
  1. broadcast_python-0.1.0/.gitignore +9 -0
  2. broadcast_python-0.1.0/CHANGELOG.md +44 -0
  3. broadcast_python-0.1.0/LICENSE +21 -0
  4. broadcast_python-0.1.0/PKG-INFO +488 -0
  5. broadcast_python-0.1.0/README.md +464 -0
  6. broadcast_python-0.1.0/pyproject.toml +90 -0
  7. broadcast_python-0.1.0/src/broadcast_python/__init__.py +66 -0
  8. broadcast_python-0.1.0/src/broadcast_python/client.py +130 -0
  9. broadcast_python-0.1.0/src/broadcast_python/configuration.py +117 -0
  10. broadcast_python-0.1.0/src/broadcast_python/connection.py +384 -0
  11. broadcast_python-0.1.0/src/broadcast_python/errors.py +76 -0
  12. broadcast_python-0.1.0/src/broadcast_python/py.typed +0 -0
  13. broadcast_python-0.1.0/src/broadcast_python/resources/__init__.py +0 -0
  14. broadcast_python-0.1.0/src/broadcast_python/resources/autopilots.py +83 -0
  15. broadcast_python-0.1.0/src/broadcast_python/resources/base.py +44 -0
  16. broadcast_python-0.1.0/src/broadcast_python/resources/broadcasts.py +44 -0
  17. broadcast_python-0.1.0/src/broadcast_python/resources/discovery.py +35 -0
  18. broadcast_python-0.1.0/src/broadcast_python/resources/email_servers.py +68 -0
  19. broadcast_python-0.1.0/src/broadcast_python/resources/migration.py +113 -0
  20. broadcast_python-0.1.0/src/broadcast_python/resources/opt_in_forms.py +62 -0
  21. broadcast_python-0.1.0/src/broadcast_python/resources/segments.py +23 -0
  22. broadcast_python-0.1.0/src/broadcast_python/resources/sequences.py +58 -0
  23. broadcast_python-0.1.0/src/broadcast_python/resources/subscribers.py +72 -0
  24. broadcast_python-0.1.0/src/broadcast_python/resources/templates.py +34 -0
  25. broadcast_python-0.1.0/src/broadcast_python/resources/transactionals.py +85 -0
  26. broadcast_python-0.1.0/src/broadcast_python/resources/webhook_endpoints.py +29 -0
  27. broadcast_python-0.1.0/src/broadcast_python/response.py +160 -0
  28. broadcast_python-0.1.0/src/broadcast_python/version.py +3 -0
  29. broadcast_python-0.1.0/src/broadcast_python/webhook.py +121 -0
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
7
+ venv/
8
+ .coverage
9
+ .DS_Store
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-07-27
6
+
7
+ First release. Feature parity with `broadcast-ruby` v0.3.0 — the reference
8
+ implementation — verified at **104/104 API operations** by the coverage report
9
+ in the `broadcast` repo.
10
+
11
+ ### Transport
12
+ - Required explicit `host`, with `BROADCAST_HOST` / `BROADCAST_API_TOKEN` env
13
+ fallbacks matching the Broadcast CLI's config keys
14
+ - Bearer auth, `User-Agent: broadcast-python/<version>`
15
+ - Response warnings surfaced, with `log` / `raise` / `ignore` modes
16
+ - `Idempotency-Key` request header and `idempotent_replay` detection
17
+ - `X-RateLimit-*` parsing; 429 retry honouring `Retry-After`, bounded by
18
+ `max_retry_delay`
19
+ - Retries on timeout and 5xx with linear backoff; 422 is never retried
20
+ - Typed errors for 401/403/404/409/422/429/5xx
21
+ - Redirects followed on GET only, never across hosts — the request carries a
22
+ bearer token, and urllib's default handler would take it along
23
+ - Raw response path for `text/plain` (`/api/v1/skill`) and binary file assets
24
+ - Channel scoping via `broadcast_channel_id` and the `with_channel` context manager
25
+ - Debug logging that never emits credentials or request bodies
26
+
27
+ ### Resources
28
+ Subscribers, broadcasts (incl. statistics), sequences (incl. steps), segments,
29
+ templates, opt-in forms, email servers, webhook endpoints, transactionals,
30
+ autopilot, discovery, and the 20 migration/export operations.
31
+
32
+ ### Non-negotiables carried over from the Ruby gem
33
+ - **Credential redaction guard** on email servers and autopilot, so a
34
+ fetch-modify-save cannot overwrite a real credential with bullet characters
35
+ - Webhook HMAC-SHA256 verification with a 5-minute window and constant-time
36
+ comparison
37
+ - No credentials or subscriber emails in debug output
38
+
39
+ ### Notes
40
+ - No runtime dependencies. The transport is `urllib` from the standard library,
41
+ so installing this cannot conflict with a pinned `requests` or `httpx`.
42
+ - The test suite uses `unittest`, so it runs with no test dependencies either.
43
+ - `broadcast.TimeoutError` shadows neither the builtin nor `socket.timeout`; it
44
+ is named for parity with the Ruby gem and pinned by a test.
@@ -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.
@@ -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