Skip to content

Commit 8d9bfe9

Browse files
Devino Solutionsclaude
andcommitted
feat: initial Sendly Python SDK
Official Python SDK for the Sendly REST API, ported 1:1 from the reference TypeScript SDK (platform monorepo packages/sdk). Surface: - Sendly client: Bearer auth, User-Agent sendly-python/<version>, 30s default timeout, {success,data} envelope unwrap, {error:{code,message}} -> typed exceptions, query None/empty skipping + repeated-key lists, 204 -> None, non-JSON success -> raw text. API key from arg or SENDLY_API_KEY (fail-loud, no degraded mode). - Resources: emails, contacts, domains, templates, webhooks, suppression. - Error hierarchy: SendlyError + 8 status-mapped subclasses. - Webhook verification: verify_signature / construct_event over bare-hex HMAC_SHA256(secret, "{timestamp}.{body}") with X-Sendly-Timestamp (ms epoch) and replay tolerance (DEFAULT_TOLERANCE_MS = 5 min; math.inf disables). Total: returns bool for every str signature, never raises on malformed headers. Matches the server's WebhookDeliveryService signing. - Full type hints, ships py.typed. Single runtime dep: httpx==0.28.1. Gates (Python 3.14 local; CI matrix 3.10 + 3.13): - ruff check: clean - ruff format --check: clean - mypy --strict src: clean (13 files) - pytest: 68 passed (hermetic, httpx MockTransport) Provenance: ported from the TypeScript SDK reference at packages/sdk (client.ts, errors.ts, resources/*, webhook-utils.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0 parents  commit 8d9bfe9

28 files changed

Lines changed: 2139 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
# Public repo -> GitHub-hosted standard runners (free for public repos).
9+
# The org's self-hosted Warp runner group (warp-ubuntu-latest-*) is deliberately
10+
# NOT used here: it may not be authorized for this repository, and the standard
11+
# ubuntu-latest runners are sufficient for a pure-Python package.
12+
jobs:
13+
test:
14+
runs-on: ubuntu-latest
15+
strategy:
16+
fail-fast: false
17+
matrix:
18+
python-version: ["3.10", "3.13"]
19+
steps:
20+
- uses: actions/checkout@v6.0.3
21+
- uses: actions/setup-python@v5.6.0
22+
with:
23+
python-version: ${{ matrix.python-version }}
24+
- name: Install package with dev dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install -e ".[dev]"
28+
- name: Ruff lint
29+
run: ruff check .
30+
- name: Ruff format check
31+
run: ruff format --check .
32+
- name: Mypy (strict)
33+
run: mypy src
34+
- name: Pytest
35+
run: pytest

.gitignore

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Virtual environments
2+
.venv/
3+
venv/
4+
env/
5+
6+
# Python caches / build artifacts
7+
__pycache__/
8+
*.py[cod]
9+
*.egg-info/
10+
dist/
11+
build/
12+
.eggs/
13+
14+
# Tooling caches
15+
.mypy_cache/
16+
.ruff_cache/
17+
.pytest_cache/
18+
.coverage
19+
htmlcov/
20+
21+
# Editor / OS
22+
.idea/
23+
.vscode/
24+
.DS_Store
25+
26+
# Local scratch logs
27+
*.log

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Devino Solutions
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.

README.md

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# Sendly Python SDK
2+
3+
Official Python SDK for the [Sendly](https://sendly.now) REST API — transactional
4+
email, contacts, domains, templates, webhooks, and suppression.
5+
6+
[![CI](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml/badge.svg)](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml)
7+
8+
- Full type hints (ships `py.typed`), `mypy --strict` clean.
9+
- One small runtime dependency: [`httpx`](https://www.python-httpx.org/).
10+
- Fail-loud by design: no silent fallbacks, no degraded mode.
11+
12+
## Installation
13+
14+
Not yet published to PyPI. Install from GitHub:
15+
16+
```bash
17+
pip install git+https://github.com/DevinoSolutions/sendly-python.git
18+
```
19+
20+
Requires Python 3.10+.
21+
22+
## Quickstart
23+
24+
The client reads your API key from the `SENDLY_API_KEY` environment variable:
25+
26+
```python
27+
from sendly import Sendly
28+
29+
sendly = Sendly() # reads SENDLY_API_KEY
30+
31+
result = sendly.emails.send(
32+
{
33+
"from": "hello@yourdomain.com",
34+
"to": "customer@example.com",
35+
"subject": "Welcome aboard",
36+
"body": "<h1>Thanks for signing up!</h1>",
37+
}
38+
)
39+
print(result["id"])
40+
```
41+
42+
Or pass the key explicitly:
43+
44+
```python
45+
sendly = Sendly(api_key="sk_live_...")
46+
```
47+
48+
If neither an explicit key nor `SENDLY_API_KEY` is set, the constructor raises a
49+
`SendlyError` immediately.
50+
51+
### Options
52+
53+
```python
54+
sendly = Sendly(
55+
api_key="sk_live_...",
56+
base_url="https://api.sendly.now", # override for staging/self-hosted
57+
timeout=30.0, # per-request seconds; 0 or None disables
58+
default_headers={"X-Trace-Id": "..."},
59+
)
60+
```
61+
62+
The client holds an internal connection pool. Reuse a single instance, and close
63+
it when done (or use it as a context manager):
64+
65+
```python
66+
with Sendly() as sendly:
67+
sendly.emails.send({...})
68+
```
69+
70+
## Usage by resource
71+
72+
### Emails
73+
74+
```python
75+
# Single send (pass idempotency_key to dedupe replays for 24h)
76+
sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"},
77+
idempotency_key="order-42-receipt")
78+
79+
# Batch send (up to 100)
80+
sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"}]})
81+
82+
# List, get, cancel a scheduled send
83+
sendly.emails.list({"limit": 20, "status": "DELIVERED"})
84+
sendly.emails.get("em_123")
85+
sendly.emails.cancel_schedule("em_123")
86+
```
87+
88+
### Contacts
89+
90+
```python
91+
sendly.contacts.create({"email": "user@example.com", "subscribed": True})
92+
sendly.contacts.upsert({"email": "user@example.com", "data": {"plan": "pro"}})
93+
sendly.contacts.list({"limit": 50, "search": "example.com"})
94+
sendly.contacts.get("c_123")
95+
sendly.contacts.update("c_123", {"data": {"plan": "enterprise"}})
96+
sendly.contacts.delete("c_123")
97+
sendly.contacts.bulk_create({"contacts": [{"email": "a@x.com"}, {"email": "b@x.com"}]})
98+
sendly.contacts.bulk_delete({"emails": ["a@x.com"]})
99+
```
100+
101+
### Domains
102+
103+
```python
104+
sendly.domains.create({"domain": "mail.yourdomain.com", "region": "us-east-1"})
105+
sendly.domains.list()
106+
sendly.domains.get("d_123")
107+
sendly.domains.verify("d_123")
108+
sendly.domains.get_verification("d_123")
109+
sendly.domains.delete("d_123")
110+
```
111+
112+
### Templates
113+
114+
```python
115+
sendly.templates.create({"name": "Welcome", "subject": "Welcome", "body": "<p>Hi</p>",
116+
"from": "a@you.com", "type": "MARKETING"})
117+
sendly.templates.list({"page": 1, "pageSize": 25})
118+
sendly.templates.get("t_123")
119+
sendly.templates.update("t_123", {"name": "Welcome v2"})
120+
sendly.templates.delete("t_123")
121+
```
122+
123+
### Webhooks
124+
125+
```python
126+
created = sendly.webhooks.create({"url": "https://you.com/hook", "eventTypes": ["email.delivered"]})
127+
# Store the signing secret now — it is only returned in full at creation/rotation.
128+
sendly.webhooks.list()
129+
sendly.webhooks.get("w_123")
130+
sendly.webhooks.update("w_123", {"status": "PAUSED"})
131+
sendly.webhooks.rotate_secret("w_123")
132+
sendly.webhooks.list_calls("w_123", {"limit": 20})
133+
sendly.webhooks.delete("w_123")
134+
```
135+
136+
### Suppression
137+
138+
```python
139+
sendly.suppression.add({"email": "bounce@example.com", "reason": "MANUAL"})
140+
sendly.suppression.list({"reason": "MANUAL", "limit": 100})
141+
sendly.suppression.get("bounce@example.com")
142+
sendly.suppression.remove("bounce@example.com")
143+
```
144+
145+
## Error handling
146+
147+
Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
148+
`error_code`, `message`, and the raw `body`:
149+
150+
```python
151+
from sendly import Sendly, SendlyValidationError, SendlyRateLimitError, SendlyError
152+
153+
sendly = Sendly()
154+
try:
155+
sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"})
156+
except SendlyValidationError as err:
157+
print("Bad request:", err.error_code, err.message)
158+
except SendlyRateLimitError:
159+
print("Slow down and retry with backoff")
160+
except SendlyError as err:
161+
print("Sendly error", err.status_code, err.message)
162+
```
163+
164+
| Exception | HTTP status |
165+
| --- | --- |
166+
| `SendlyValidationError` | 400 |
167+
| `SendlyAuthenticationError` | 401 |
168+
| `SendlyPermissionError` | 403 |
169+
| `SendlyNotFoundError` | 404 |
170+
| `SendlyConflictError` | 409 |
171+
| `SendlyRateLimitError` | 429 |
172+
| `SendlyServerError` | 5xx |
173+
| `SendlyConnectionError` | transport failure (status `0`) |
174+
175+
All inherit from `SendlyError`.
176+
177+
## Verifying webhooks
178+
179+
Every delivery is signed. Verify it against the **raw** request body — do not
180+
parse the JSON first. Two headers are sent:
181+
182+
- `X-Sendly-Signature` — bare lowercase hex HMAC-SHA256 of `"{timestamp}.{body}"`
183+
(no `sha256=` prefix).
184+
- `X-Sendly-Timestamp` — the signing time as a **millisecond** Unix epoch.
185+
186+
`verify_signature` also enforces replay protection: a delivery whose timestamp is
187+
more than `DEFAULT_TOLERANCE_MS` (5 minutes) from now is rejected. Pass
188+
`tolerance_ms=math.inf` to disable that check.
189+
190+
```python
191+
import os
192+
from flask import Flask, request
193+
from sendly import construct_event
194+
195+
app = Flask(__name__)
196+
197+
@app.post("/webhook")
198+
def webhook():
199+
payload = request.get_data() # raw bytes
200+
signature = request.headers.get("X-Sendly-Signature", "")
201+
timestamp = request.headers.get("X-Sendly-Timestamp", "")
202+
secret = os.environ["SENDLY_WEBHOOK_SECRET"]
203+
try:
204+
event = construct_event(payload, signature, timestamp, secret)
205+
except ValueError:
206+
return "Invalid signature", 400
207+
# handle event["event"], event["data"], ...
208+
return "", 200
209+
```
210+
211+
`verify_signature(payload, signature, timestamp, secret, *, tolerance_ms=...) -> bool`
212+
is also exported if you only need the boolean check. Both use a constant-time
213+
comparison and reject a stale or non-numeric timestamp.
214+
215+
## Async
216+
217+
Only a synchronous client ships in v0.1. An `httpx.AsyncClient`-backed async
218+
variant is planned.
219+
220+
## Development
221+
222+
```bash
223+
python -m venv .venv
224+
source .venv/bin/activate # Windows: .venv\Scripts\activate
225+
pip install -e ".[dev]"
226+
227+
ruff check .
228+
ruff format --check .
229+
mypy src
230+
pytest
231+
```
232+
233+
Tests are fully hermetic (httpx `MockTransport`) and hit no network.
234+
235+
## Documentation
236+
237+
Full API reference: <https://docs.sendly.now>
238+
239+
## License
240+
241+
MIT — see [LICENSE](LICENSE).

pyproject.toml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
[build-system]
2+
requires = ["hatchling==1.30.1"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "sendly"
7+
version = "0.1.0"
8+
description = "Official Sendly Python SDK"
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
license = { text = "MIT" }
12+
authors = [{ name = "Devino Solutions", email = "dev@devino.ca" }]
13+
keywords = ["sendly", "email", "api", "sdk", "transactional-email", "webhooks"]
14+
dependencies = ["httpx==0.28.1"]
15+
classifiers = [
16+
"Development Status :: 4 - Beta",
17+
"Intended Audience :: Developers",
18+
"License :: OSI Approved :: MIT License",
19+
"Operating System :: OS Independent",
20+
"Programming Language :: Python :: 3",
21+
"Programming Language :: Python :: 3.10",
22+
"Programming Language :: Python :: 3.11",
23+
"Programming Language :: Python :: 3.12",
24+
"Programming Language :: Python :: 3.13",
25+
"Programming Language :: Python :: 3 :: Only",
26+
"Topic :: Communications :: Email",
27+
"Topic :: Software Development :: Libraries :: Python Modules",
28+
"Typing :: Typed",
29+
]
30+
31+
[project.optional-dependencies]
32+
dev = [
33+
"pytest==8.3.4",
34+
"ruff==0.9.6",
35+
"mypy==1.15.0",
36+
]
37+
38+
[project.urls]
39+
Homepage = "https://sendly.now"
40+
Documentation = "https://docs.sendly.now"
41+
Repository = "https://github.com/DevinoSolutions/sendly-python"
42+
43+
[tool.hatch.build.targets.wheel]
44+
packages = ["src/sendly"]
45+
46+
[tool.ruff]
47+
line-length = 100
48+
target-version = "py310"
49+
src = ["src", "tests"]
50+
51+
[tool.ruff.lint]
52+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "W"]
53+
# Line length is owned by the formatter; the linter does not double-check it.
54+
ignore = ["E501"]
55+
56+
[tool.mypy]
57+
python_version = "3.10"
58+
strict = true
59+
files = ["src"]
60+
61+
[tool.pytest.ini_options]
62+
testpaths = ["tests"]
63+
addopts = "-ra"

0 commit comments

Comments
 (0)