beaconbox 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.
- beaconbox-0.1.0/.github/workflows/ci.yml +45 -0
- beaconbox-0.1.0/.github/workflows/publish.yml +100 -0
- beaconbox-0.1.0/.gitignore +19 -0
- beaconbox-0.1.0/CHANGELOG.md +36 -0
- beaconbox-0.1.0/CONTRIBUTING.md +7 -0
- beaconbox-0.1.0/LICENSE +21 -0
- beaconbox-0.1.0/PKG-INFO +336 -0
- beaconbox-0.1.0/README.md +313 -0
- beaconbox-0.1.0/pyproject.toml +73 -0
- beaconbox-0.1.0/src/beaconbox/__init__.py +125 -0
- beaconbox-0.1.0/src/beaconbox/_core.py +248 -0
- beaconbox-0.1.0/src/beaconbox/_logging.py +81 -0
- beaconbox-0.1.0/src/beaconbox/_ops.py +196 -0
- beaconbox-0.1.0/src/beaconbox/_transport.py +290 -0
- beaconbox-0.1.0/src/beaconbox/_version.py +3 -0
- beaconbox-0.1.0/src/beaconbox/async_resources.py +257 -0
- beaconbox-0.1.0/src/beaconbox/client.py +294 -0
- beaconbox-0.1.0/src/beaconbox/enums.py +186 -0
- beaconbox-0.1.0/src/beaconbox/errors.py +145 -0
- beaconbox-0.1.0/src/beaconbox/models.py +737 -0
- beaconbox-0.1.0/src/beaconbox/py.typed +0 -0
- beaconbox-0.1.0/src/beaconbox/resources.py +408 -0
- beaconbox-0.1.0/src/beaconbox/retry.py +98 -0
- beaconbox-0.1.0/src/beaconbox/webhooks.py +132 -0
- beaconbox-0.1.0/tests/__init__.py +0 -0
- beaconbox-0.1.0/tests/conftest.py +187 -0
- beaconbox-0.1.0/tests/live/README.md +57 -0
- beaconbox-0.1.0/tests/live/__init__.py +0 -0
- beaconbox-0.1.0/tests/live/test_live.py +267 -0
- beaconbox-0.1.0/tests/test_async.py +237 -0
- beaconbox-0.1.0/tests/test_client.py +209 -0
- beaconbox-0.1.0/tests/test_core.py +203 -0
- beaconbox-0.1.0/tests/test_logging.py +243 -0
- beaconbox-0.1.0/tests/test_models.py +259 -0
- beaconbox-0.1.0/tests/test_packaging.py +119 -0
- beaconbox-0.1.0/tests/test_resources.py +494 -0
- beaconbox-0.1.0/tests/test_retry.py +239 -0
- beaconbox-0.1.0/tests/test_webhooks.py +137 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# Unit tests only. The live suite is gated on BEACONBOX_LIVE_URL and skips itself here: it needs
|
|
4
|
+
# a running BeaconBox, which is exercised in the private platform repository before a release is
|
|
5
|
+
# cut.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches: [main]
|
|
9
|
+
pull_request:
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
name: Python ${{ matrix.python }}
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
# The versions pyproject.toml declares support for. 3.10 is the floor and is listed first
|
|
18
|
+
# because it is the one that breaks: it is where `tomllib` is absent and where a 3.11+ only
|
|
19
|
+
# syntax would surface.
|
|
20
|
+
matrix:
|
|
21
|
+
python: ['3.10', '3.11', '3.12', '3.13']
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v7
|
|
24
|
+
|
|
25
|
+
- name: Install uv
|
|
26
|
+
uses: astral-sh/setup-uv@v9.0.0
|
|
27
|
+
with:
|
|
28
|
+
enable-cache: true
|
|
29
|
+
|
|
30
|
+
- name: Set up Python ${{ matrix.python }}
|
|
31
|
+
run: uv python install ${{ matrix.python }}
|
|
32
|
+
|
|
33
|
+
- name: Sync deps
|
|
34
|
+
run: uv sync --all-extras --dev
|
|
35
|
+
|
|
36
|
+
- name: Ruff
|
|
37
|
+
run: |
|
|
38
|
+
uv run ruff check .
|
|
39
|
+
uv run ruff format --check .
|
|
40
|
+
|
|
41
|
+
- name: Mypy
|
|
42
|
+
run: uv run mypy
|
|
43
|
+
|
|
44
|
+
- name: Pytest
|
|
45
|
+
run: uv run pytest -q
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Pushing a tag `vX.Y.Z` to this repository is what publishes a release.
|
|
4
|
+
#
|
|
5
|
+
# The distribution is `beaconbox`; the import name is `beaconbox` too.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags:
|
|
9
|
+
- 'v*'
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
name: Build + publish
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
permissions:
|
|
16
|
+
# PyPI trusted publishing (OIDC). No API token is stored anywhere: PyPI is configured to
|
|
17
|
+
# trust this workflow, in this repository, and mints a short-lived credential per run.
|
|
18
|
+
#
|
|
19
|
+
# For the FIRST release the project does not exist on PyPI yet, so there is nothing to
|
|
20
|
+
# attach a publisher to — configure a *pending* publisher first at
|
|
21
|
+
# https://pypi.org/manage/account/publishing/. Its **PyPI project name is the distribution
|
|
22
|
+
# name, `beaconbox`** — not `beaconbox-python`, which is this repository. Getting that wrong
|
|
23
|
+
# fails the upload with "invalid-publisher" *after* the tag is public, because PyPI looks
|
|
24
|
+
# for a publisher for the project being uploaded and finds none.
|
|
25
|
+
id-token: write
|
|
26
|
+
# Spelling `permissions` at all drops every default to `none`, so checkout's read has to be
|
|
27
|
+
# asked for back.
|
|
28
|
+
contents: read
|
|
29
|
+
|
|
30
|
+
# **Deliberately no `environment:`.** An environment is worth adding for a publish job — it is
|
|
31
|
+
# where a required reviewer would go — but it has to be added on both sides at once: PyPI
|
|
32
|
+
# matches the publisher on (project, repository, workflow, environment), so naming one here
|
|
33
|
+
# while the publisher says "(Any)" is a second thing to keep in step for no gain today.
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v7
|
|
36
|
+
|
|
37
|
+
- name: Install uv
|
|
38
|
+
uses: astral-sh/setup-uv@v9.0.0
|
|
39
|
+
with:
|
|
40
|
+
enable-cache: true
|
|
41
|
+
|
|
42
|
+
# The version is declared once, in `_version.py`, and `pyproject.toml` reads it through
|
|
43
|
+
# hatchling's `dynamic = ["version"]`. So this reads the module, NOT `project.version` —
|
|
44
|
+
# that key does not exist in this project and looking it up raises rather than comparing.
|
|
45
|
+
#
|
|
46
|
+
# The tag is checked against it because a tag is the only part of a release nobody
|
|
47
|
+
# validates by running it: the wheel would build and upload happily under a number that
|
|
48
|
+
# disagrees with what the client reports in its User-Agent.
|
|
49
|
+
- name: Verify the tag matches the package version
|
|
50
|
+
run: |
|
|
51
|
+
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
|
52
|
+
PKG_VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' src/beaconbox/_version.py)"
|
|
53
|
+
if [ -z "$PKG_VERSION" ]; then
|
|
54
|
+
echo "::error::could not read __version__ from src/beaconbox/_version.py"
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
|
|
58
|
+
echo "::error::tag $TAG_VERSION does not match __version__ $PKG_VERSION"
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
echo "publishing $PKG_VERSION"
|
|
62
|
+
|
|
63
|
+
- name: Sync deps
|
|
64
|
+
run: uv sync --all-extras --dev
|
|
65
|
+
|
|
66
|
+
# The gate runs here and not only on the pushing side, because this is the tree that gets
|
|
67
|
+
# uploaded. A tag can be pushed at any commit by anyone with write access.
|
|
68
|
+
- name: Test
|
|
69
|
+
run: uv run pytest -q
|
|
70
|
+
|
|
71
|
+
- name: Lint
|
|
72
|
+
run: |
|
|
73
|
+
uv run ruff check .
|
|
74
|
+
uv run ruff format --check .
|
|
75
|
+
|
|
76
|
+
- name: Types
|
|
77
|
+
run: uv run mypy
|
|
78
|
+
|
|
79
|
+
- name: Build
|
|
80
|
+
run: uv build
|
|
81
|
+
|
|
82
|
+
# `py.typed` reaching the wheel is the one packaging property that fails silently in a
|
|
83
|
+
# consumer's project: PEP 561 makes a type checker ignore every annotation in a package
|
|
84
|
+
# without it, with no error anywhere.
|
|
85
|
+
- name: Check the artefact
|
|
86
|
+
run: |
|
|
87
|
+
python - <<'PY'
|
|
88
|
+
import glob, sys, zipfile
|
|
89
|
+
|
|
90
|
+
wheel = glob.glob("dist/*.whl")[0]
|
|
91
|
+
names = zipfile.ZipFile(wheel).namelist()
|
|
92
|
+
if not any(n.endswith("beaconbox/py.typed") for n in names):
|
|
93
|
+
sys.exit(f"{wheel} ships no py.typed")
|
|
94
|
+
if any(n.startswith("tests/") for n in names):
|
|
95
|
+
sys.exit(f"{wheel} ships the test suite")
|
|
96
|
+
print(f"{wheel}: ok")
|
|
97
|
+
PY
|
|
98
|
+
|
|
99
|
+
- name: Publish
|
|
100
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
|
|
11
|
+
# A lock file is for an application, not a library. This package is installed *next to* a
|
|
12
|
+
# consumer's own dependencies and resolved against them, so a lock committed here pins nothing
|
|
13
|
+
# for anybody who installs it — and hatchling puts it in the sdist, where it is noise. The PHP
|
|
14
|
+
# SDK gitignores composer.lock for the same reason.
|
|
15
|
+
#
|
|
16
|
+
# `uv run` writes one as a side effect, so this line is what keeps it from being committed by
|
|
17
|
+
# accident rather than on purpose. Tracking lock files for the SDKs would be a deliberate
|
|
18
|
+
# decision for both of them, not one that arrives with a stray command.
|
|
19
|
+
uv.lock
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
|
|
6
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `BeaconBox` and `AsyncBeaconBox` clients, with the same 18 operations and identical signatures.
|
|
13
|
+
- Typed models for every response, with `py.typed`. Unknown fields remain available via `.raw`.
|
|
14
|
+
- Cursor pagination via `messages.iterate()`, and `async for` on the async client.
|
|
15
|
+
- Webhook signature verification via `beaconbox.webhooks.verify()`.
|
|
16
|
+
- Automatic `Idempotency-Key` on every write, reused across the SDK's own retries. Override per
|
|
17
|
+
call with `idempotency_key=`.
|
|
18
|
+
- Retries with exponential backoff and jitter on connection errors, 429 and 5xx. Configure with
|
|
19
|
+
`RetryPolicy`; `max_retries=0` disables them.
|
|
20
|
+
- `Retry-After` honoured up to `RetryPolicy.max_retry_after` (30s). Beyond it, `RateLimitError` is
|
|
21
|
+
raised with `retry_after` set rather than retrying.
|
|
22
|
+
- Skipped SMS and WhatsApp channels are reported on the result; they do not raise.
|
|
23
|
+
- Configurable `base_url`, `timeout`, `retry_policy`, `verify`, `max_connections` and
|
|
24
|
+
`user_agent_suffix`. An `httpx` client can be injected via `http_client=` instead.
|
|
25
|
+
- Opt-in logging on the `beaconbox` logger. Silent by default; the SDK configures nothing.
|
|
26
|
+
- Plain `http` is refused except on localhost.
|
|
27
|
+
- Redirects are not followed.
|
|
28
|
+
- API keys, newly minted keys and webhook endpoint secrets are excluded from `repr()`.
|
|
29
|
+
- Log records carry route templates (`/recipients/{email}/sms`), never interpolated paths.
|
|
30
|
+
|
|
31
|
+
### Requirements
|
|
32
|
+
|
|
33
|
+
- Python 3.10+
|
|
34
|
+
- [httpx](https://www.python-httpx.org) `>=0.28.1,<1`
|
|
35
|
+
|
|
36
|
+
[0.1.0]: https://github.com/beaconbox-eu/beaconbox-python/releases/tag/v0.1.0
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
This repository is a **read-only mirror**. It is published from the BeaconBox monorepo, and
|
|
4
|
+
anything pushed here directly is overwritten by the next release.
|
|
5
|
+
|
|
6
|
+
Bug reports and feature requests are welcome as issues. For a code change, open an issue first and
|
|
7
|
+
we will apply it upstream with attribution.
|
beaconbox-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BeaconBox
|
|
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.
|
beaconbox-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: beaconbox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for BeaconBox, order updates your customers actually receive.
|
|
5
|
+
Project-URL: Homepage, https://beaconbox.eu
|
|
6
|
+
Project-URL: Documentation, https://docs.beaconbox.eu
|
|
7
|
+
Project-URL: Source, https://github.com/beaconbox-eu/beaconbox-python
|
|
8
|
+
Project-URL: Issues, https://github.com/beaconbox-eu/beaconbox-python/issues
|
|
9
|
+
Author-email: BeaconBox <sdk@beaconbox.eu>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: beaconbox,eu,notifications,sms,transactional,whatsapp
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx<1,>=0.28.1
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# beaconbox
|
|
25
|
+
|
|
26
|
+
Official Python SDK for [BeaconBox](https://beaconbox.eu), order updates your customers actually
|
|
27
|
+
receive.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install beaconbox
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Python 3.10+. One runtime dependency, [httpx](https://www.python-httpx.org), which is also the
|
|
34
|
+
only HTTP library where the sync and the async client share an API, so this SDK's logic is
|
|
35
|
+
written once rather than twice.
|
|
36
|
+
|
|
37
|
+
## Push an update
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from beaconbox import BeaconBox
|
|
41
|
+
|
|
42
|
+
client = BeaconBox() # reads BEACONBOX_API_KEY
|
|
43
|
+
|
|
44
|
+
result = client.messages.push(
|
|
45
|
+
recipient_email="buyer@example.com",
|
|
46
|
+
subject="Your order has shipped",
|
|
47
|
+
body="Tracking XY123456789EE. Estimated delivery Thursday.",
|
|
48
|
+
kind="updateable",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
print(result.id) # 3xg39cvt8a46
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The customer gets an email whose link opens their inbox already signed in. No password, no
|
|
55
|
+
account to create.
|
|
56
|
+
|
|
57
|
+
Re-push the same `subject` with `kind="updateable"` to overwrite it in place, quietly. Add
|
|
58
|
+
`notify=True` to force a nudge on an update, or `obsoletes=[...]` to grey out messages this one
|
|
59
|
+
replaces.
|
|
60
|
+
|
|
61
|
+
## Async
|
|
62
|
+
|
|
63
|
+
Same client, same signatures, awaited. Nothing blocks the event loop, including retry backoff.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from beaconbox import AsyncBeaconBox
|
|
67
|
+
|
|
68
|
+
async with AsyncBeaconBox() as client:
|
|
69
|
+
result = await client.messages.push(
|
|
70
|
+
recipient_email="buyer@example.com",
|
|
71
|
+
subject="Your order has shipped",
|
|
72
|
+
body="Tracking XY123456789EE.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async for message in client.messages.iterate(recipient_email="buyer@example.com"):
|
|
76
|
+
print(message.id, message.delivery.opened)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Two things to know before anything else
|
|
80
|
+
|
|
81
|
+
### 1. Read the response, not the status code
|
|
82
|
+
|
|
83
|
+
A push returns **201 even when the SMS or WhatsApp message was not sent.** The update is already
|
|
84
|
+
in the customer's inbox and the email nudge has gone, so a paid channel that could not send
|
|
85
|
+
reports a reason and the request still succeeded.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from beaconbox import SkipReason
|
|
89
|
+
|
|
90
|
+
result = client.messages.push(
|
|
91
|
+
recipient_email="buyer@example.com",
|
|
92
|
+
recipient_phone="+37255550134",
|
|
93
|
+
subject="Your order has shipped",
|
|
94
|
+
body="Tracking XY123456789EE.",
|
|
95
|
+
channels=["sms"],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if result.sms and result.sms.skipped_reason == SkipReason.INSUFFICIENT_CREDIT:
|
|
99
|
+
# Top up, then: client.messages.resend_sms(result.id)
|
|
100
|
+
...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**This SDK does not raise on a skip**, deliberately. Treating one as an error is what invites a
|
|
104
|
+
retry, and a retry of a push that already succeeded is a second message to a real person.
|
|
105
|
+
|
|
106
|
+
### 2. Idempotency is handled for you, and you can do better
|
|
107
|
+
|
|
108
|
+
Every write carries an `Idempotency-Key`. This SDK generates one per call and **reuses it across
|
|
109
|
+
its own retries**, so a connection that dies with the answer in flight cannot become a duplicate
|
|
110
|
+
email and a duplicate charged SMS.
|
|
111
|
+
|
|
112
|
+
Pass your own whenever you have a natural key:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
client.messages.push(..., idempotency_key="order-4711-shipped")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Then a retry from *anywhere*, your queue, a cron, a human clicking twice, collapses onto the same
|
|
119
|
+
key rather than only the retries this SDK makes internally.
|
|
120
|
+
|
|
121
|
+
## Everything else
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from beaconbox import MessagePush
|
|
125
|
+
|
|
126
|
+
# Delivery status
|
|
127
|
+
client.messages.get("3xg39cvt8a46")
|
|
128
|
+
|
|
129
|
+
# Every message, following cursors. A generator, so a year of history is not held in memory
|
|
130
|
+
for message in client.messages.iterate(recipient_email="buyer@example.com"):
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
# Take one back: withdrawn for the recipient, any queued nudge called off, no credit spent
|
|
134
|
+
client.messages.retract("3xg39cvt8a46")
|
|
135
|
+
|
|
136
|
+
# Up to 100 pushes. Always 200: read result.failed, not the status code
|
|
137
|
+
result = client.messages.push_batch(
|
|
138
|
+
[
|
|
139
|
+
MessagePush(recipient_email="a@example.com", subject="Shipped", body="..."),
|
|
140
|
+
MessagePush(recipient_email="b@example.com", subject="Shipped", body="..."),
|
|
141
|
+
]
|
|
142
|
+
)
|
|
143
|
+
for item in result.failures:
|
|
144
|
+
print(item.index, item.error_code)
|
|
145
|
+
|
|
146
|
+
# Contact details. Reads are masked: a leaked key must not dump a phone book
|
|
147
|
+
client.recipients.set_phone("buyer@example.com", "+37255550134")
|
|
148
|
+
client.recipients.clear_phone("buyer@example.com")
|
|
149
|
+
|
|
150
|
+
# The prepaid balance. One balance, shared by SMS and WhatsApp
|
|
151
|
+
client.credits.balance()
|
|
152
|
+
|
|
153
|
+
# Keys and webhook endpoints
|
|
154
|
+
client.keys.create("orders service")
|
|
155
|
+
client.webhook_endpoints.create("https://example.com/hooks") # empty list means every event
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Pay only for the customers the email did not reach
|
|
159
|
+
|
|
160
|
+
`escalate_if_unread_after_minutes` holds the paid channel back. The SMS is sent only if the
|
|
161
|
+
recipient still has not opened the message after that long, and if they open it first, nothing is
|
|
162
|
+
sent and nothing is charged.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
client.messages.push(
|
|
166
|
+
recipient_email="buyer@example.com",
|
|
167
|
+
recipient_phone="+37255550134",
|
|
168
|
+
subject="Action needed on your order",
|
|
169
|
+
body="We could not process your payment.",
|
|
170
|
+
channels=["sms"],
|
|
171
|
+
escalate_if_unread_after_minutes=120,
|
|
172
|
+
)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Erasing a recipient's WhatsApp history
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
receipt = client.recipients.erase_whatsapp("buyer@example.com")
|
|
179
|
+
|
|
180
|
+
# The half only you can finish: erased replies whose words may already be in *your* mailboxes.
|
|
181
|
+
yours = receipt.replies_a_forward_email_may_have_carried
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Verifying webhooks
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
import os
|
|
188
|
+
from beaconbox import WebhookVerificationError, WebhookEventType, webhooks
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@app.post("/hooks/beaconbox")
|
|
192
|
+
async def hook(request):
|
|
193
|
+
try:
|
|
194
|
+
event = webhooks.verify(
|
|
195
|
+
await request.body(), # the RAW body, byte for byte
|
|
196
|
+
request.headers["X-BeaconBox-Signature"],
|
|
197
|
+
os.environ["BEACONBOX_WEBHOOK_SECRET"],
|
|
198
|
+
)
|
|
199
|
+
except WebhookVerificationError:
|
|
200
|
+
return Response(status_code=400)
|
|
201
|
+
|
|
202
|
+
if event.type == WebhookEventType.MESSAGE_BOUNCED:
|
|
203
|
+
...
|
|
204
|
+
return {"ok": True}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**Pass the raw body.** Decoding to a dict and re-encoding changes the bytes over key order and
|
|
208
|
+
whitespace, and the signature stops matching, in production, on a payload shaped slightly
|
|
209
|
+
differently from the one you tested with. The helper also checks the timestamp (which is signed,
|
|
210
|
+
so a captured delivery cannot be replayed) and compares in constant time.
|
|
211
|
+
|
|
212
|
+
Deliveries are **retried**, so the same `event.id` can arrive twice. Deduplicate on it.
|
|
213
|
+
|
|
214
|
+
## Errors
|
|
215
|
+
|
|
216
|
+
Everything inherits from `beaconbox.BeaconBoxError`.
|
|
217
|
+
|
|
218
|
+
| Exception | When |
|
|
219
|
+
| --- | --- |
|
|
220
|
+
| `AuthenticationError` | 401, key missing, malformed or revoked |
|
|
221
|
+
| `PermissionDeniedError` | 403 |
|
|
222
|
+
| `InvalidRequestError` | 422, a malformed field, or a reused idempotency key with a different body |
|
|
223
|
+
| `ResourceMissingError` | 404, no such id. Also what another business's id looks like, deliberately |
|
|
224
|
+
| `ConflictError` | 409, already sent, or an identical request still in flight |
|
|
225
|
+
| `RateLimitError` | 429, after the SDK has already retried |
|
|
226
|
+
| `ServerError` | 5xx, after the SDK has already retried |
|
|
227
|
+
| `APIConnectionError` | no answer at all. **Not** proof the work did not happen |
|
|
228
|
+
| `WebhookVerificationError` | a delivery could not be proven to be ours |
|
|
229
|
+
|
|
230
|
+
Branch on `error.error_code` (a stable dotted string such as `message.not_found`), not on the
|
|
231
|
+
message text.
|
|
232
|
+
|
|
233
|
+
## Configuration
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
from beaconbox import BeaconBox, RetryPolicy
|
|
237
|
+
|
|
238
|
+
client = BeaconBox(
|
|
239
|
+
api_key="bbx_live_...", # or $BEACONBOX_API_KEY
|
|
240
|
+
base_url="https://api.beaconbox.eu", # or $BEACONBOX_BASE_URL
|
|
241
|
+
timeout=30.0,
|
|
242
|
+
retry_policy=RetryPolicy(max_retries=2),
|
|
243
|
+
max_connections=20,
|
|
244
|
+
user_agent_suffix="acme-orders/2.1",
|
|
245
|
+
)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Reuse one client: it holds a connection pool, and it is safe to share between threads. Close it,
|
|
249
|
+
or use it as a context manager.
|
|
250
|
+
|
|
251
|
+
Inside a job runner that already retries, pass `RetryPolicy(max_retries=0)` so the two schedules
|
|
252
|
+
do not multiply.
|
|
253
|
+
|
|
254
|
+
### Backpressure
|
|
255
|
+
|
|
256
|
+
A connection failure, a 429 and a 5xx are retried; a 4xx is not, because sending the same wrong
|
|
257
|
+
request again asks the same question. Backoff is exponential with full jitter, since the failure
|
|
258
|
+
being absorbed is synchronised across every worker you run.
|
|
259
|
+
|
|
260
|
+
**A `Retry-After` is honoured in full, not shortened to the backoff cap.** It is the server's own
|
|
261
|
+
answer to when it will be ready, and retrying earlier only earns a second 429. Jitter is added *on
|
|
262
|
+
top* of it rather than sampled from within it — the herd is at its worst here, because every worker
|
|
263
|
+
that hit the same 429 was handed the same number.
|
|
264
|
+
|
|
265
|
+
If the server asks for longer than `RetryPolicy.max_retry_after` (30s by default), the SDK **stops
|
|
266
|
+
rather than retrying early** and raises `RateLimitError` with `retry_after` set. Blocking for the
|
|
267
|
+
cap and being refused anyway helps nobody; the number is what you need to schedule a real retry.
|
|
268
|
+
|
|
269
|
+
### Concurrency
|
|
270
|
+
|
|
271
|
+
`max_connections` is a ceiling, and exceeding it does not look like one: the overflow queues for a
|
|
272
|
+
free connection, and a queue wait that outlives the pool timeout surfaces as `APIConnectionError`
|
|
273
|
+
rather than as anything mentioning a pool. If you fan out more concurrent calls than the ceiling —
|
|
274
|
+
`asyncio.gather` over a few hundred pushes makes that easy — raise it to match.
|
|
275
|
+
|
|
276
|
+
## Logging
|
|
277
|
+
|
|
278
|
+
Standard library `logging`, under the `beaconbox` logger, and **silent until you ask**. The SDK
|
|
279
|
+
attaches a `NullHandler` and configures nothing else: no `basicConfig`, no handlers, no level on
|
|
280
|
+
the root logger.
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
import logging
|
|
284
|
+
|
|
285
|
+
logging.getLogger("beaconbox").setLevel(logging.DEBUG)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
| Level | What |
|
|
289
|
+
| --- | --- |
|
|
290
|
+
| `DEBUG` | every request and response, with status and elapsed time |
|
|
291
|
+
| `WARNING` | a retry (with reason and backoff), and giving up after the last one |
|
|
292
|
+
|
|
293
|
+
Nothing is emitted at `INFO` or above in normal operation, so a `WARNING` from this SDK always
|
|
294
|
+
means something went wrong.
|
|
295
|
+
|
|
296
|
+
Each record carries a `beaconbox` attribute with structured fields (`route`, `attempt`,
|
|
297
|
+
`status_code`, `elapsed_ms`, `idempotency_key`) for a JSON formatter.
|
|
298
|
+
|
|
299
|
+
**Nothing sensitive is ever logged.** Not the API key or any header, not request or response
|
|
300
|
+
bodies, not the query string, and not the interpolated URL path. A route template is logged
|
|
301
|
+
instead, so `/recipients/buyer@example.com/sms` appears as `/recipients/{email}/sms`. The fields
|
|
302
|
+
are an allow-list rather than a redaction pass, because redaction is a list of things somebody
|
|
303
|
+
remembered to hide and the field added next year is not on it.
|
|
304
|
+
|
|
305
|
+
### Security defaults you cannot accidentally lose
|
|
306
|
+
|
|
307
|
+
- **Plain `http` is refused** for anything but localhost, so a misconfigured `base_url` cannot put
|
|
308
|
+
your API key on the wire in clear.
|
|
309
|
+
- **Redirects are never followed.** httpx would re-send the `Authorization` header to wherever a
|
|
310
|
+
redirect points.
|
|
311
|
+
- The key is never in a `repr`, and `NewApiKey.__repr__` redacts the minted key.
|
|
312
|
+
- Webhook signatures are compared in constant time, over a signed timestamp.
|
|
313
|
+
|
|
314
|
+
Need a corporate CA bundle? Pass `verify=` a path or an `ssl.SSLContext`. Need a proxy or custom
|
|
315
|
+
instrumentation? Pass your own `http_client=httpx.Client(...)`, and then you own closing it.
|
|
316
|
+
|
|
317
|
+
`timeout` and `verify` are **refused** alongside `http_client`, rather than silently ignored: they
|
|
318
|
+
are settings on the client you supplied, and accepting them while doing nothing is how somebody
|
|
319
|
+
discovers during an incident that the timeout they set was never applied.
|
|
320
|
+
|
|
321
|
+
## Development
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
uv venv && uv pip install -e '.[dev]'
|
|
325
|
+
pytest # hermetic, no network
|
|
326
|
+
mypy src tests
|
|
327
|
+
ruff check .
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
The live suite runs against a real BeaconBox. See [`tests/live/README.md`](tests/live/README.md).
|
|
331
|
+
|
|
332
|
+
## Licence
|
|
333
|
+
|
|
334
|
+
MIT. See [LICENSE](LICENSE).
|
|
335
|
+
|
|
336
|
+
BeaconBox is a product of BloomHarbor OÜ, a company registered in Estonia.
|