sendly-python 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sendly_python-0.2.0/CHANGELOG.md +73 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/PKG-INFO +173 -1
- {sendly_python-0.1.0 → sendly_python-0.2.0}/README.md +172 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/pyproject.toml +1 -1
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/__init__.py +18 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/client.py +47 -8
- sendly_python-0.2.0/src/sendly/errors.py +196 -0
- sendly_python-0.2.0/src/sendly/resources/_pagination.py +49 -0
- sendly_python-0.2.0/src/sendly/resources/analytics.py +49 -0
- sendly_python-0.2.0/src/sendly/resources/campaigns.py +135 -0
- sendly_python-0.2.0/src/sendly/resources/events.py +98 -0
- sendly_python-0.2.0/src/sendly/resources/lists.py +59 -0
- sendly_python-0.2.0/src/sendly/resources/segments.py +106 -0
- sendly_python-0.2.0/src/sendly/resources/usage.py +27 -0
- sendly_python-0.2.0/src/sendly/resources/workflows.py +139 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/types.py +41 -0
- sendly_python-0.2.0/tests/fixtures/openapi.json +11361 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/support.py +39 -0
- sendly_python-0.2.0/tests/test_analytics.py +70 -0
- sendly_python-0.2.0/tests/test_campaigns.py +262 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_contract.py +136 -2
- sendly_python-0.2.0/tests/test_errors_problem.py +198 -0
- sendly_python-0.2.0/tests/test_events.py +192 -0
- sendly_python-0.2.0/tests/test_lists.py +123 -0
- sendly_python-0.2.0/tests/test_segments.py +125 -0
- sendly_python-0.2.0/tests/test_usage.py +56 -0
- sendly_python-0.2.0/tests/test_workflows.py +159 -0
- sendly_python-0.1.0/CHANGELOG.md +0 -29
- sendly_python-0.1.0/src/sendly/errors.py +0 -92
- sendly_python-0.1.0/src/sendly/resources/events.py +0 -26
- sendly_python-0.1.0/tests/fixtures/openapi.json +0 -5729
- sendly_python-0.1.0/tests/test_events.py +0 -63
- {sendly_python-0.1.0 → sendly_python-0.2.0}/.github/workflows/ci.yml +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/.github/workflows/release.yml +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/.gitignore +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/LICENSE +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/scripts/sync_spec.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/py.typed +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/__init__.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/_helpers.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/contacts.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/domains.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/emails.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/suppression.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/templates.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/verify.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/webhooks.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/webhook_utils.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_client.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_contacts.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_domains.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_emails.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_suppression.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_templates.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_verify.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_webhook_verify.py +0 -0
- {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_webhooks.py +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `sendly-python` are documented here. This project adheres to
|
|
4
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.2.0]
|
|
9
|
+
|
|
10
|
+
Adds Sendly's `/api/v1` surface. Purely additive — every existing method keeps
|
|
11
|
+
its name, signature, and behaviour.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **New resources for the `/api/v1` surface**, wired onto the same client:
|
|
16
|
+
`sendly.campaigns`, `sendly.segments`, `sendly.workflows`, `sendly.analytics`
|
|
17
|
+
and `sendly.usage`, covering all 33 v1 operations. Unlike the legacy `/api/*`
|
|
18
|
+
resources, these return **bare resource bodies** — there is no
|
|
19
|
+
`{success, data}` envelope to unwrap.
|
|
20
|
+
- **v1 methods on the existing `events` resource**: `events.record` (the v1
|
|
21
|
+
counterpart of `events.track`, which is unchanged), `events.list`,
|
|
22
|
+
`events.list_names` and `events.stats`. `record` takes no `idempotency_key`:
|
|
23
|
+
events are append-only and the API deliberately does not ledger them.
|
|
24
|
+
- **Auto-pagination.** Each of the six cursor-paginated v1 listings gains an
|
|
25
|
+
`iter_*` companion yielding individual items and following the cursor for you:
|
|
26
|
+
`campaigns.iter_list`, `segments.iter_list`, `segments.iter_list_contacts`,
|
|
27
|
+
`workflows.iter_list`, `workflows.iter_list_executions`, `events.iter_list`.
|
|
28
|
+
The v1 list envelope is `{data, has_more, next_cursor}` with `limit` (1-100,
|
|
29
|
+
default 20) and `after` — no total, deliberately. Changing filters
|
|
30
|
+
mid-pagination invalidates the cursor and returns `422 validation_error`, so
|
|
31
|
+
the iterators hold the query fixed and only advance `after`.
|
|
32
|
+
- **RFC 9457 error support.** `application/problem+json` responses from `/api/v1`
|
|
33
|
+
map to the **same** exception classes as the legacy envelope, keyed off the
|
|
34
|
+
same statuses — existing `except` blocks are unaffected. The problem's `code`
|
|
35
|
+
becomes `err.error_code` (e.g. `scope_missing`, `quota_exhausted`,
|
|
36
|
+
`idempotency_key_reused`) and its `detail` (falling back to `title`) becomes
|
|
37
|
+
`err.message`. Two fields are new on `SendlyError`:
|
|
38
|
+
- `err.request_id` — correlation id from the problem document, `None` on the
|
|
39
|
+
legacy surface;
|
|
40
|
+
- `err.field_errors` — per-field `{pointer, code, message}` entries from a v1
|
|
41
|
+
`validation_error`, `None` when absent. The legacy per-field breakdown stays
|
|
42
|
+
at `err.body["error"]["details"]["errors"]`.
|
|
43
|
+
- **`sendly.lists`** — `lists.subscribe(id, body)` and
|
|
44
|
+
`lists.unsubscribe(id, body)` wrap the newly published
|
|
45
|
+
`POST /api/lists/{id}/subscribe` and `.../unsubscribe` operations. Both accept
|
|
46
|
+
sending-only (`pk_*`) keys so they can back a public form. On a double opt-in
|
|
47
|
+
list, subscribe returns `PENDING` with a `confirmToken` and Sendly does **not**
|
|
48
|
+
send the confirmation email — the caller delivers it. Re-subscribing an address
|
|
49
|
+
that opted out needs `allowResubscribe: true` or fails with
|
|
50
|
+
`409 RESUBSCRIBE_CONFIRMATION_REQUIRED`.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- Re-synced the vendored OpenAPI spec (`tests/fixtures/openapi.json`) to the
|
|
55
|
+
committed monorepo contract. The client stays thin (opaque `Mapping` bodies),
|
|
56
|
+
so these are contract/behaviour clarifications rather than method-signature
|
|
57
|
+
changes:
|
|
58
|
+
- **Deletes now return HTTP `200` with the deleted resource's id** (was `204`
|
|
59
|
+
No Content) for `contacts.delete` and `templates.delete`. The SDK still
|
|
60
|
+
discards the body and returns `None` — no consumer change.
|
|
61
|
+
- **Invalid input now raises `SendlyValidationError` from HTTP `422`**
|
|
62
|
+
(`error_code == "VALIDATION_ERROR"`) with a per-field breakdown at
|
|
63
|
+
`err.body["error"]["details"]["errors"]`. Previously invalid input came back
|
|
64
|
+
as `400`. Both `400` and `422` map to `SendlyValidationError`, so
|
|
65
|
+
`except SendlyValidationError` continues to catch validation failures.
|
|
66
|
+
- **Contacts bulk ops (`bulk_create`, `bulk_delete`) against an unresolved
|
|
67
|
+
project now return `422 VALIDATION_ERROR`** (was a `NO_PROJECT` error).
|
|
68
|
+
- **`templates.list` is cursor-paginated** (`limit` / `cursor`) — the former
|
|
69
|
+
`page` / `pageSize` query params are gone. `contacts.list` was already
|
|
70
|
+
cursor-based and is unchanged.
|
|
71
|
+
- Error envelopes on migrated routes now include `success: false` alongside
|
|
72
|
+
`error.{message,code}`; error parsing reads `message`/`code` and is
|
|
73
|
+
unaffected by the additive fields.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sendly-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Official Sendly Python SDK
|
|
5
5
|
Project-URL: Homepage, https://sendly.now
|
|
6
6
|
Project-URL: Documentation, https://docs.sendly.now
|
|
@@ -62,6 +62,25 @@ pip install git+https://github.com/DevinoSolutions/sendly-python.git
|
|
|
62
62
|
|
|
63
63
|
Requires Python 3.10+.
|
|
64
64
|
|
|
65
|
+
## Already on Resend, SendGrid, Postmark, Mailgun, or Plunk?
|
|
66
|
+
|
|
67
|
+
You don't even need this SDK to try Sendly. The API also speaks the
|
|
68
|
+
transactional-send dialect of those providers — keep the vendor SDK you already
|
|
69
|
+
run and change **two things**: the base URL and the API key.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import resend # your existing Resend integration
|
|
73
|
+
|
|
74
|
+
resend.api_key = "sk_your_sendly_key"
|
|
75
|
+
resend.api_url = "https://api.sendly.now/api/compat/resend"
|
|
76
|
+
# resend.Emails.send(...) now sends through Sendly — same code, same shapes.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Every compat request runs through the same pipeline as the native API (domain
|
|
80
|
+
verification, suppression, limits), and anything a dialect can express that
|
|
81
|
+
Sendly doesn't support returns a clean error in that vendor's own error shape.
|
|
82
|
+
Per-provider guides: [docs.sendly.now/migrate](https://docs.sendly.now/migrate).
|
|
83
|
+
|
|
65
84
|
## Quickstart
|
|
66
85
|
|
|
67
86
|
The client reads your API key from the `SENDLY_API_KEY` environment variable:
|
|
@@ -207,6 +226,125 @@ sendly.suppression.get("bounce@example.com")
|
|
|
207
226
|
sendly.suppression.remove("bounce@example.com")
|
|
208
227
|
```
|
|
209
228
|
|
|
229
|
+
### Lists
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
# Both calls accept sending-only (pk_*) keys, so they can back a public form.
|
|
233
|
+
result = sendly.lists.subscribe("l_123", {"email": "user@example.com"})
|
|
234
|
+
|
|
235
|
+
# On a double opt-in list the membership is PENDING and carries a confirmToken.
|
|
236
|
+
# Sendly does NOT send the confirmation email — deliver this link yourself.
|
|
237
|
+
if result["status"] == "PENDING":
|
|
238
|
+
confirm_url = f"https://api.sendly.now/api/lists/confirm?token={result['confirmToken']}"
|
|
239
|
+
|
|
240
|
+
# Re-subscribing an address that opted out needs an explicit opt-in, or the call
|
|
241
|
+
# fails with 409 RESUBSCRIBE_CONFIRMATION_REQUIRED.
|
|
242
|
+
sendly.lists.subscribe("l_123", {"email": "user@example.com", "allowResubscribe": True})
|
|
243
|
+
|
|
244
|
+
sendly.lists.unsubscribe("l_123", {"email": "user@example.com"})
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## The v1 API
|
|
248
|
+
|
|
249
|
+
`campaigns`, `segments`, `workflows`, `analytics` and `usage` — plus the v1
|
|
250
|
+
methods on `events` — speak Sendly's `/api/v1` surface. Same client, same API
|
|
251
|
+
key; two differences worth knowing:
|
|
252
|
+
|
|
253
|
+
- **Responses are bare resource bodies.** There is no `{success, data}` envelope
|
|
254
|
+
to unwrap, so what the API documents is exactly what you get.
|
|
255
|
+
- **Errors are RFC 9457 problem documents.** They raise the same exception
|
|
256
|
+
classes as the legacy surface, with two extra fields — see
|
|
257
|
+
[Error handling](#error-handling).
|
|
258
|
+
|
|
259
|
+
### Campaigns
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
campaign = sendly.campaigns.create(
|
|
263
|
+
{
|
|
264
|
+
"name": "August launch",
|
|
265
|
+
"subject": "We are live",
|
|
266
|
+
"body": "<p>Hello</p>",
|
|
267
|
+
"from": "team@you.com",
|
|
268
|
+
"audience_type": "ALL",
|
|
269
|
+
},
|
|
270
|
+
idempotency_key="august-launch",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Send now, or schedule it. Key the replay — a duplicate send mails the audience twice.
|
|
274
|
+
sendly.campaigns.send(campaign["id"], idempotency_key="august-launch-send")
|
|
275
|
+
sendly.campaigns.send(campaign["id"], {"scheduled_for": "2026-09-01T10:00:00Z"})
|
|
276
|
+
|
|
277
|
+
sendly.campaigns.pause(campaign["id"])
|
|
278
|
+
sendly.campaigns.resume(campaign["id"])
|
|
279
|
+
sendly.campaigns.cancel(campaign["id"])
|
|
280
|
+
|
|
281
|
+
stats = sendly.campaigns.stats(campaign["id"])
|
|
282
|
+
print(stats["delivered"], stats["open_rate"])
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Pagination
|
|
286
|
+
|
|
287
|
+
Every v1 list answers `{data, has_more, next_cursor}` — an opaque forward-only
|
|
288
|
+
cursor, and no total. Page it yourself with `limit` (1–100, default 20) and
|
|
289
|
+
`after`:
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
page = sendly.campaigns.list({"limit": 50})
|
|
293
|
+
while page["has_more"]:
|
|
294
|
+
page = sendly.campaigns.list({"limit": 50, "after": page["next_cursor"]})
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
…or let the `iter_*` companion do it. It yields individual items and follows the
|
|
298
|
+
cursor until the last page:
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
for campaign in sendly.campaigns.iter_list({"limit": 100}):
|
|
302
|
+
print(campaign["name"], campaign["status"])
|
|
303
|
+
|
|
304
|
+
for contact in sendly.segments.iter_list_contacts("seg_123"):
|
|
305
|
+
print(contact["email"])
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Keep your filters identical for every page of one walk. Changing them
|
|
309
|
+
mid-pagination invalidates the cursor and the API answers `422 validation_error`
|
|
310
|
+
telling you to restart from the first page — which is exactly why `iter_*` holds
|
|
311
|
+
the query fixed and only advances `after`.
|
|
312
|
+
|
|
313
|
+
Available on the six cursor-paginated listings: `campaigns.iter_list`,
|
|
314
|
+
`segments.iter_list`, `segments.iter_list_contacts`, `workflows.iter_list`,
|
|
315
|
+
`workflows.iter_list_executions`, `events.iter_list`. The analytics endpoints and
|
|
316
|
+
`events.list_names` / `events.stats` return a bounded aggregate rather than a
|
|
317
|
+
cursor, so they have no iterator.
|
|
318
|
+
|
|
319
|
+
### Segments, workflows, events, analytics, usage
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
segment = sendly.segments.create({"name": "Power users", "type": "DYNAMIC",
|
|
323
|
+
"condition": {"field": "plan", "op": "eq", "value": "pro"}})
|
|
324
|
+
sendly.segments.list_contacts(segment["id"], {"limit": 50})
|
|
325
|
+
|
|
326
|
+
workflow = sendly.workflows.create({"name": "Welcome", "event_name": "signup.completed"})
|
|
327
|
+
sendly.workflows.start_execution(workflow["id"], {"contact_id": "c_123"})
|
|
328
|
+
# Executions are cancelled by execution id alone — not nested under the workflow.
|
|
329
|
+
sendly.workflows.cancel_execution("exe_123")
|
|
330
|
+
sendly.workflows.stats(workflow["id"], {"from": "2026-08-01"})
|
|
331
|
+
|
|
332
|
+
# events.record is the v1 counterpart of the legacy events.track. Same effect,
|
|
333
|
+
# v1 dialect. It takes no idempotency_key: events are append-only and the API
|
|
334
|
+
# deliberately does not ledger them.
|
|
335
|
+
sendly.events.record({"name": "signup.completed", "contact_id": "c_123", "data": {"plan": "pro"}})
|
|
336
|
+
sendly.events.list({"event_name": "signup.completed", "limit": 20})
|
|
337
|
+
sendly.events.list_names()
|
|
338
|
+
sendly.events.stats({"from": "2026-08-01", "to": "2026-08-31"})
|
|
339
|
+
|
|
340
|
+
sendly.analytics.timeseries({"from": "2026-08-01", "to": "2026-08-31"})
|
|
341
|
+
sendly.analytics.campaigns()
|
|
342
|
+
sendly.analytics.top_campaigns({"limit": 5})
|
|
343
|
+
|
|
344
|
+
usage = sendly.usage.get()
|
|
345
|
+
print(usage["plan"], usage["monthly"])
|
|
346
|
+
```
|
|
347
|
+
|
|
210
348
|
## Error handling
|
|
211
349
|
|
|
212
350
|
Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
|
|
@@ -244,6 +382,40 @@ Invalid input raises `SendlyValidationError`. Migrated routes report it as HTTP
|
|
|
244
382
|
`err.body["error"]["details"]["errors"]`; legacy/malformed requests still use
|
|
245
383
|
`400`. Both surface as `SendlyValidationError`.
|
|
246
384
|
|
|
385
|
+
### v1 errors (RFC 9457)
|
|
386
|
+
|
|
387
|
+
The `/api/v1` surface reports failures as `application/problem+json` documents.
|
|
388
|
+
They raise the **same** exception classes, keyed off the same statuses, so
|
|
389
|
+
existing `except` blocks keep working. Three things move:
|
|
390
|
+
|
|
391
|
+
- `error_code` comes from the problem's `code` — a lowercase, machine-readable
|
|
392
|
+
value like `scope_missing`, `quota_exhausted`, or `idempotency_key_reused`.
|
|
393
|
+
- `err.request_id` carries the correlation id. Quote it in support requests.
|
|
394
|
+
- `err.field_errors` carries the per-field breakdown on a `validation_error`,
|
|
395
|
+
each entry `{pointer, code, message}` with an RFC 6901 JSON Pointer.
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
from sendly import Sendly, SendlyValidationError, SendlyRateLimitError
|
|
399
|
+
|
|
400
|
+
sendly = Sendly()
|
|
401
|
+
try:
|
|
402
|
+
sendly.campaigns.create({"name": "Launch"})
|
|
403
|
+
except SendlyValidationError as err:
|
|
404
|
+
print(err.error_code, err.message, err.request_id)
|
|
405
|
+
for field in err.field_errors or []:
|
|
406
|
+
print(f" {field['pointer']}: {field['message']}")
|
|
407
|
+
except SendlyRateLimitError as err:
|
|
408
|
+
# Two different failures share this class — check the code before retrying.
|
|
409
|
+
if err.error_code == "quota_exhausted":
|
|
410
|
+
print("Plan limit reached; backing off will not help")
|
|
411
|
+
else:
|
|
412
|
+
print("Too fast — retry with backoff")
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
The full problem document stays on `err.body`, so `type`, `title` and `instance`
|
|
416
|
+
remain reachable. On the legacy surface `request_id` and `field_errors` are
|
|
417
|
+
`None`.
|
|
418
|
+
|
|
247
419
|
## Verifying webhooks
|
|
248
420
|
|
|
249
421
|
Every delivery is signed. Verify it against the **raw** request body — do not
|
|
@@ -30,6 +30,25 @@ pip install git+https://github.com/DevinoSolutions/sendly-python.git
|
|
|
30
30
|
|
|
31
31
|
Requires Python 3.10+.
|
|
32
32
|
|
|
33
|
+
## Already on Resend, SendGrid, Postmark, Mailgun, or Plunk?
|
|
34
|
+
|
|
35
|
+
You don't even need this SDK to try Sendly. The API also speaks the
|
|
36
|
+
transactional-send dialect of those providers — keep the vendor SDK you already
|
|
37
|
+
run and change **two things**: the base URL and the API key.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import resend # your existing Resend integration
|
|
41
|
+
|
|
42
|
+
resend.api_key = "sk_your_sendly_key"
|
|
43
|
+
resend.api_url = "https://api.sendly.now/api/compat/resend"
|
|
44
|
+
# resend.Emails.send(...) now sends through Sendly — same code, same shapes.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Every compat request runs through the same pipeline as the native API (domain
|
|
48
|
+
verification, suppression, limits), and anything a dialect can express that
|
|
49
|
+
Sendly doesn't support returns a clean error in that vendor's own error shape.
|
|
50
|
+
Per-provider guides: [docs.sendly.now/migrate](https://docs.sendly.now/migrate).
|
|
51
|
+
|
|
33
52
|
## Quickstart
|
|
34
53
|
|
|
35
54
|
The client reads your API key from the `SENDLY_API_KEY` environment variable:
|
|
@@ -175,6 +194,125 @@ sendly.suppression.get("bounce@example.com")
|
|
|
175
194
|
sendly.suppression.remove("bounce@example.com")
|
|
176
195
|
```
|
|
177
196
|
|
|
197
|
+
### Lists
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
# Both calls accept sending-only (pk_*) keys, so they can back a public form.
|
|
201
|
+
result = sendly.lists.subscribe("l_123", {"email": "user@example.com"})
|
|
202
|
+
|
|
203
|
+
# On a double opt-in list the membership is PENDING and carries a confirmToken.
|
|
204
|
+
# Sendly does NOT send the confirmation email — deliver this link yourself.
|
|
205
|
+
if result["status"] == "PENDING":
|
|
206
|
+
confirm_url = f"https://api.sendly.now/api/lists/confirm?token={result['confirmToken']}"
|
|
207
|
+
|
|
208
|
+
# Re-subscribing an address that opted out needs an explicit opt-in, or the call
|
|
209
|
+
# fails with 409 RESUBSCRIBE_CONFIRMATION_REQUIRED.
|
|
210
|
+
sendly.lists.subscribe("l_123", {"email": "user@example.com", "allowResubscribe": True})
|
|
211
|
+
|
|
212
|
+
sendly.lists.unsubscribe("l_123", {"email": "user@example.com"})
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## The v1 API
|
|
216
|
+
|
|
217
|
+
`campaigns`, `segments`, `workflows`, `analytics` and `usage` — plus the v1
|
|
218
|
+
methods on `events` — speak Sendly's `/api/v1` surface. Same client, same API
|
|
219
|
+
key; two differences worth knowing:
|
|
220
|
+
|
|
221
|
+
- **Responses are bare resource bodies.** There is no `{success, data}` envelope
|
|
222
|
+
to unwrap, so what the API documents is exactly what you get.
|
|
223
|
+
- **Errors are RFC 9457 problem documents.** They raise the same exception
|
|
224
|
+
classes as the legacy surface, with two extra fields — see
|
|
225
|
+
[Error handling](#error-handling).
|
|
226
|
+
|
|
227
|
+
### Campaigns
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
campaign = sendly.campaigns.create(
|
|
231
|
+
{
|
|
232
|
+
"name": "August launch",
|
|
233
|
+
"subject": "We are live",
|
|
234
|
+
"body": "<p>Hello</p>",
|
|
235
|
+
"from": "team@you.com",
|
|
236
|
+
"audience_type": "ALL",
|
|
237
|
+
},
|
|
238
|
+
idempotency_key="august-launch",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Send now, or schedule it. Key the replay — a duplicate send mails the audience twice.
|
|
242
|
+
sendly.campaigns.send(campaign["id"], idempotency_key="august-launch-send")
|
|
243
|
+
sendly.campaigns.send(campaign["id"], {"scheduled_for": "2026-09-01T10:00:00Z"})
|
|
244
|
+
|
|
245
|
+
sendly.campaigns.pause(campaign["id"])
|
|
246
|
+
sendly.campaigns.resume(campaign["id"])
|
|
247
|
+
sendly.campaigns.cancel(campaign["id"])
|
|
248
|
+
|
|
249
|
+
stats = sendly.campaigns.stats(campaign["id"])
|
|
250
|
+
print(stats["delivered"], stats["open_rate"])
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Pagination
|
|
254
|
+
|
|
255
|
+
Every v1 list answers `{data, has_more, next_cursor}` — an opaque forward-only
|
|
256
|
+
cursor, and no total. Page it yourself with `limit` (1–100, default 20) and
|
|
257
|
+
`after`:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
page = sendly.campaigns.list({"limit": 50})
|
|
261
|
+
while page["has_more"]:
|
|
262
|
+
page = sendly.campaigns.list({"limit": 50, "after": page["next_cursor"]})
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
…or let the `iter_*` companion do it. It yields individual items and follows the
|
|
266
|
+
cursor until the last page:
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
for campaign in sendly.campaigns.iter_list({"limit": 100}):
|
|
270
|
+
print(campaign["name"], campaign["status"])
|
|
271
|
+
|
|
272
|
+
for contact in sendly.segments.iter_list_contacts("seg_123"):
|
|
273
|
+
print(contact["email"])
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Keep your filters identical for every page of one walk. Changing them
|
|
277
|
+
mid-pagination invalidates the cursor and the API answers `422 validation_error`
|
|
278
|
+
telling you to restart from the first page — which is exactly why `iter_*` holds
|
|
279
|
+
the query fixed and only advances `after`.
|
|
280
|
+
|
|
281
|
+
Available on the six cursor-paginated listings: `campaigns.iter_list`,
|
|
282
|
+
`segments.iter_list`, `segments.iter_list_contacts`, `workflows.iter_list`,
|
|
283
|
+
`workflows.iter_list_executions`, `events.iter_list`. The analytics endpoints and
|
|
284
|
+
`events.list_names` / `events.stats` return a bounded aggregate rather than a
|
|
285
|
+
cursor, so they have no iterator.
|
|
286
|
+
|
|
287
|
+
### Segments, workflows, events, analytics, usage
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
segment = sendly.segments.create({"name": "Power users", "type": "DYNAMIC",
|
|
291
|
+
"condition": {"field": "plan", "op": "eq", "value": "pro"}})
|
|
292
|
+
sendly.segments.list_contacts(segment["id"], {"limit": 50})
|
|
293
|
+
|
|
294
|
+
workflow = sendly.workflows.create({"name": "Welcome", "event_name": "signup.completed"})
|
|
295
|
+
sendly.workflows.start_execution(workflow["id"], {"contact_id": "c_123"})
|
|
296
|
+
# Executions are cancelled by execution id alone — not nested under the workflow.
|
|
297
|
+
sendly.workflows.cancel_execution("exe_123")
|
|
298
|
+
sendly.workflows.stats(workflow["id"], {"from": "2026-08-01"})
|
|
299
|
+
|
|
300
|
+
# events.record is the v1 counterpart of the legacy events.track. Same effect,
|
|
301
|
+
# v1 dialect. It takes no idempotency_key: events are append-only and the API
|
|
302
|
+
# deliberately does not ledger them.
|
|
303
|
+
sendly.events.record({"name": "signup.completed", "contact_id": "c_123", "data": {"plan": "pro"}})
|
|
304
|
+
sendly.events.list({"event_name": "signup.completed", "limit": 20})
|
|
305
|
+
sendly.events.list_names()
|
|
306
|
+
sendly.events.stats({"from": "2026-08-01", "to": "2026-08-31"})
|
|
307
|
+
|
|
308
|
+
sendly.analytics.timeseries({"from": "2026-08-01", "to": "2026-08-31"})
|
|
309
|
+
sendly.analytics.campaigns()
|
|
310
|
+
sendly.analytics.top_campaigns({"limit": 5})
|
|
311
|
+
|
|
312
|
+
usage = sendly.usage.get()
|
|
313
|
+
print(usage["plan"], usage["monthly"])
|
|
314
|
+
```
|
|
315
|
+
|
|
178
316
|
## Error handling
|
|
179
317
|
|
|
180
318
|
Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
|
|
@@ -212,6 +350,40 @@ Invalid input raises `SendlyValidationError`. Migrated routes report it as HTTP
|
|
|
212
350
|
`err.body["error"]["details"]["errors"]`; legacy/malformed requests still use
|
|
213
351
|
`400`. Both surface as `SendlyValidationError`.
|
|
214
352
|
|
|
353
|
+
### v1 errors (RFC 9457)
|
|
354
|
+
|
|
355
|
+
The `/api/v1` surface reports failures as `application/problem+json` documents.
|
|
356
|
+
They raise the **same** exception classes, keyed off the same statuses, so
|
|
357
|
+
existing `except` blocks keep working. Three things move:
|
|
358
|
+
|
|
359
|
+
- `error_code` comes from the problem's `code` — a lowercase, machine-readable
|
|
360
|
+
value like `scope_missing`, `quota_exhausted`, or `idempotency_key_reused`.
|
|
361
|
+
- `err.request_id` carries the correlation id. Quote it in support requests.
|
|
362
|
+
- `err.field_errors` carries the per-field breakdown on a `validation_error`,
|
|
363
|
+
each entry `{pointer, code, message}` with an RFC 6901 JSON Pointer.
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
from sendly import Sendly, SendlyValidationError, SendlyRateLimitError
|
|
367
|
+
|
|
368
|
+
sendly = Sendly()
|
|
369
|
+
try:
|
|
370
|
+
sendly.campaigns.create({"name": "Launch"})
|
|
371
|
+
except SendlyValidationError as err:
|
|
372
|
+
print(err.error_code, err.message, err.request_id)
|
|
373
|
+
for field in err.field_errors or []:
|
|
374
|
+
print(f" {field['pointer']}: {field['message']}")
|
|
375
|
+
except SendlyRateLimitError as err:
|
|
376
|
+
# Two different failures share this class — check the code before retrying.
|
|
377
|
+
if err.error_code == "quota_exhausted":
|
|
378
|
+
print("Plan limit reached; backing off will not help")
|
|
379
|
+
else:
|
|
380
|
+
print("Too fast — retry with backoff")
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
The full problem document stays on `err.body`, so `type`, `title` and `instance`
|
|
384
|
+
remain reachable. On the legacy surface `request_id` and `field_errors` are
|
|
385
|
+
`None`.
|
|
386
|
+
|
|
215
387
|
## Verifying webhooks
|
|
216
388
|
|
|
217
389
|
Every delivery is signed. Verify it against the **raw** request body — do not
|
|
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
|
|
|
8
8
|
# the PyPI trusted-publisher configuration). The import package is
|
|
9
9
|
# unchanged (`import sendly`) — see [tool.hatch.build.targets.wheel] below.
|
|
10
10
|
name = "sendly-python"
|
|
11
|
-
version = "0.
|
|
11
|
+
version = "0.2.0"
|
|
12
12
|
description = "Official Sendly Python SDK"
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.10"
|
|
@@ -6,6 +6,12 @@ Example:
|
|
|
6
6
|
>>> sendly.emails.send(
|
|
7
7
|
... {"from": "a@b.com", "to": "c@d.com", "subject": "hi", "body": "<p>hi</p>"}
|
|
8
8
|
... )
|
|
9
|
+
|
|
10
|
+
The same client also speaks the ``/api/v1`` surface — campaigns, segments,
|
|
11
|
+
workflows, analytics, usage, and the v1 event methods:
|
|
12
|
+
|
|
13
|
+
>>> for campaign in sendly.campaigns.iter_list({"limit": 100}):
|
|
14
|
+
... print(campaign["name"], campaign["status"])
|
|
9
15
|
"""
|
|
10
16
|
|
|
11
17
|
from __future__ import annotations
|
|
@@ -22,14 +28,20 @@ from sendly.errors import (
|
|
|
22
28
|
SendlyServerError,
|
|
23
29
|
SendlyValidationError,
|
|
24
30
|
)
|
|
31
|
+
from sendly.resources.analytics import AnalyticsResource
|
|
32
|
+
from sendly.resources.campaigns import CampaignsResource
|
|
25
33
|
from sendly.resources.contacts import ContactsResource
|
|
26
34
|
from sendly.resources.domains import DomainsResource
|
|
27
35
|
from sendly.resources.emails import EmailsResource
|
|
28
36
|
from sendly.resources.events import EventsResource
|
|
37
|
+
from sendly.resources.lists import ListsResource
|
|
38
|
+
from sendly.resources.segments import SegmentsResource
|
|
29
39
|
from sendly.resources.suppression import SuppressionResource
|
|
30
40
|
from sendly.resources.templates import TemplatesResource
|
|
41
|
+
from sendly.resources.usage import UsageResource
|
|
31
42
|
from sendly.resources.verify import VerifyResource
|
|
32
43
|
from sendly.resources.webhooks import WebhooksResource
|
|
44
|
+
from sendly.resources.workflows import WorkflowsResource
|
|
33
45
|
from sendly.webhook_utils import DEFAULT_TOLERANCE_MS, construct_event, verify_signature
|
|
34
46
|
|
|
35
47
|
__version__ = SDK_VERSION
|
|
@@ -38,10 +50,14 @@ __all__ = [
|
|
|
38
50
|
"DEFAULT_BASE_URL",
|
|
39
51
|
"DEFAULT_TOLERANCE_MS",
|
|
40
52
|
"SDK_VERSION",
|
|
53
|
+
"AnalyticsResource",
|
|
54
|
+
"CampaignsResource",
|
|
41
55
|
"ContactsResource",
|
|
42
56
|
"DomainsResource",
|
|
43
57
|
"EmailsResource",
|
|
44
58
|
"EventsResource",
|
|
59
|
+
"ListsResource",
|
|
60
|
+
"SegmentsResource",
|
|
45
61
|
"Sendly",
|
|
46
62
|
"SendlyAuthenticationError",
|
|
47
63
|
"SendlyConflictError",
|
|
@@ -54,8 +70,10 @@ __all__ = [
|
|
|
54
70
|
"SendlyValidationError",
|
|
55
71
|
"SuppressionResource",
|
|
56
72
|
"TemplatesResource",
|
|
73
|
+
"UsageResource",
|
|
57
74
|
"VerifyResource",
|
|
58
75
|
"WebhooksResource",
|
|
76
|
+
"WorkflowsResource",
|
|
59
77
|
"__version__",
|
|
60
78
|
"construct_event",
|
|
61
79
|
"verify_signature",
|
|
@@ -7,6 +7,14 @@ Ported from the reference TypeScript SDK's ``client.ts``. Behavioural parity:
|
|
|
7
7
|
* Error envelope ``{error: {code, message}}`` mapped to typed exceptions.
|
|
8
8
|
* Query params skip ``None``/empty-string; list values append repeated keys.
|
|
9
9
|
* 204 / No-Content -> ``None``; non-JSON success body -> raw text.
|
|
10
|
+
|
|
11
|
+
One client, two response dialects. The legacy ``/api/*`` resources wrap results
|
|
12
|
+
in ``{success, data}`` and report failures as ``{error: {code, message}}``. The
|
|
13
|
+
``/api/v1/*`` resources (``campaigns``, ``segments``, ``workflows``,
|
|
14
|
+
``analytics``, ``usage``, and the v1 methods on ``events``) return the resource
|
|
15
|
+
body directly — no envelope, so they never call :meth:`Sendly.unwrap` — and
|
|
16
|
+
report failures as RFC 9457 problem documents. Both dialects raise the same
|
|
17
|
+
:class:`~sendly.errors.SendlyError` subclasses.
|
|
10
18
|
"""
|
|
11
19
|
|
|
12
20
|
from __future__ import annotations
|
|
@@ -18,15 +26,27 @@ from urllib.parse import urlencode
|
|
|
18
26
|
|
|
19
27
|
import httpx
|
|
20
28
|
|
|
21
|
-
from sendly.errors import
|
|
29
|
+
from sendly.errors import (
|
|
30
|
+
SendlyConnectionError,
|
|
31
|
+
SendlyError,
|
|
32
|
+
error_from_problem,
|
|
33
|
+
error_from_response,
|
|
34
|
+
is_problem_document,
|
|
35
|
+
)
|
|
36
|
+
from sendly.resources.analytics import AnalyticsResource
|
|
37
|
+
from sendly.resources.campaigns import CampaignsResource
|
|
22
38
|
from sendly.resources.contacts import ContactsResource
|
|
23
39
|
from sendly.resources.domains import DomainsResource
|
|
24
40
|
from sendly.resources.emails import EmailsResource
|
|
25
41
|
from sendly.resources.events import EventsResource
|
|
42
|
+
from sendly.resources.lists import ListsResource
|
|
43
|
+
from sendly.resources.segments import SegmentsResource
|
|
26
44
|
from sendly.resources.suppression import SuppressionResource
|
|
27
45
|
from sendly.resources.templates import TemplatesResource
|
|
46
|
+
from sendly.resources.usage import UsageResource
|
|
28
47
|
from sendly.resources.verify import VerifyResource
|
|
29
48
|
from sendly.resources.webhooks import WebhooksResource
|
|
49
|
+
from sendly.resources.workflows import WorkflowsResource
|
|
30
50
|
|
|
31
51
|
if TYPE_CHECKING:
|
|
32
52
|
from collections.abc import Mapping
|
|
@@ -37,7 +57,7 @@ if TYPE_CHECKING:
|
|
|
37
57
|
__all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
|
|
38
58
|
|
|
39
59
|
#: Package version. Kept in sync with ``pyproject.toml``.
|
|
40
|
-
SDK_VERSION = "0.
|
|
60
|
+
SDK_VERSION = "0.2.0"
|
|
41
61
|
|
|
42
62
|
#: Default production API base. Override via ``base_url`` for staging/self-hosted.
|
|
43
63
|
DEFAULT_BASE_URL = "https://api.sendly.now"
|
|
@@ -56,9 +76,11 @@ def _stringify(value: Any) -> str:
|
|
|
56
76
|
class Sendly:
|
|
57
77
|
"""Sendly SDK entry point.
|
|
58
78
|
|
|
59
|
-
Construct once with an API key and reuse the resource accessors
|
|
60
|
-
|
|
61
|
-
``verify``, ``webhooks``, ``suppression``
|
|
79
|
+
Construct once with an API key and reuse the resource accessors for all
|
|
80
|
+
calls: ``emails``, ``contacts``, ``events``, ``domains``, ``templates``,
|
|
81
|
+
``verify``, ``webhooks``, ``suppression`` and ``lists`` on the legacy
|
|
82
|
+
surface, plus ``campaigns``, ``segments``, ``workflows``, ``analytics`` and
|
|
83
|
+
``usage`` on ``/api/v1``.
|
|
62
84
|
|
|
63
85
|
Args:
|
|
64
86
|
api_key: Project API key (``sk_*`` for full access, ``pk_*`` for
|
|
@@ -112,6 +134,14 @@ class Sendly:
|
|
|
112
134
|
self.verify = VerifyResource(self)
|
|
113
135
|
self.webhooks = WebhooksResource(self)
|
|
114
136
|
self.suppression = SuppressionResource(self)
|
|
137
|
+
self.lists = ListsResource(self)
|
|
138
|
+
# /api/v1 surface. Same client, same auth; bare resource bodies instead
|
|
139
|
+
# of the legacy {success, data} envelope, and RFC 9457 problem errors.
|
|
140
|
+
self.campaigns = CampaignsResource(self)
|
|
141
|
+
self.segments = SegmentsResource(self)
|
|
142
|
+
self.workflows = WorkflowsResource(self)
|
|
143
|
+
self.analytics = AnalyticsResource(self)
|
|
144
|
+
self.usage = UsageResource(self)
|
|
115
145
|
|
|
116
146
|
def request(
|
|
117
147
|
self,
|
|
@@ -180,7 +210,9 @@ class Sendly:
|
|
|
180
210
|
return text
|
|
181
211
|
|
|
182
212
|
if not response.is_success:
|
|
183
|
-
self._raise_from_body(
|
|
213
|
+
self._raise_from_body(
|
|
214
|
+
response.status_code, parsed, response.headers.get("content-type")
|
|
215
|
+
)
|
|
184
216
|
|
|
185
217
|
return parsed
|
|
186
218
|
|
|
@@ -238,9 +270,16 @@ class Sendly:
|
|
|
238
270
|
body = json.loads(text)
|
|
239
271
|
except json.JSONDecodeError:
|
|
240
272
|
body = None
|
|
241
|
-
self._raise_from_body(response.status_code, body)
|
|
273
|
+
self._raise_from_body(response.status_code, body, response.headers.get("content-type"))
|
|
274
|
+
|
|
275
|
+
def _raise_from_body(
|
|
276
|
+
self, status_code: int, body: Any, content_type: str | None = None
|
|
277
|
+
) -> NoReturn:
|
|
278
|
+
# /api/v1 speaks RFC 9457; the legacy surface speaks {success, error}.
|
|
279
|
+
# Both land on the same exception classes, keyed off the status.
|
|
280
|
+
if is_problem_document(body, content_type):
|
|
281
|
+
raise error_from_problem(status_code, body)
|
|
242
282
|
|
|
243
|
-
def _raise_from_body(self, status_code: int, body: Any) -> NoReturn:
|
|
244
283
|
error = body.get("error") if isinstance(body, dict) else None
|
|
245
284
|
error = error if isinstance(error, dict) else {}
|
|
246
285
|
raw_message = error.get("message")
|