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.
Files changed (57) hide show
  1. sendly_python-0.2.0/CHANGELOG.md +73 -0
  2. {sendly_python-0.1.0 → sendly_python-0.2.0}/PKG-INFO +173 -1
  3. {sendly_python-0.1.0 → sendly_python-0.2.0}/README.md +172 -0
  4. {sendly_python-0.1.0 → sendly_python-0.2.0}/pyproject.toml +1 -1
  5. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/__init__.py +18 -0
  6. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/client.py +47 -8
  7. sendly_python-0.2.0/src/sendly/errors.py +196 -0
  8. sendly_python-0.2.0/src/sendly/resources/_pagination.py +49 -0
  9. sendly_python-0.2.0/src/sendly/resources/analytics.py +49 -0
  10. sendly_python-0.2.0/src/sendly/resources/campaigns.py +135 -0
  11. sendly_python-0.2.0/src/sendly/resources/events.py +98 -0
  12. sendly_python-0.2.0/src/sendly/resources/lists.py +59 -0
  13. sendly_python-0.2.0/src/sendly/resources/segments.py +106 -0
  14. sendly_python-0.2.0/src/sendly/resources/usage.py +27 -0
  15. sendly_python-0.2.0/src/sendly/resources/workflows.py +139 -0
  16. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/types.py +41 -0
  17. sendly_python-0.2.0/tests/fixtures/openapi.json +11361 -0
  18. {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/support.py +39 -0
  19. sendly_python-0.2.0/tests/test_analytics.py +70 -0
  20. sendly_python-0.2.0/tests/test_campaigns.py +262 -0
  21. {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_contract.py +136 -2
  22. sendly_python-0.2.0/tests/test_errors_problem.py +198 -0
  23. sendly_python-0.2.0/tests/test_events.py +192 -0
  24. sendly_python-0.2.0/tests/test_lists.py +123 -0
  25. sendly_python-0.2.0/tests/test_segments.py +125 -0
  26. sendly_python-0.2.0/tests/test_usage.py +56 -0
  27. sendly_python-0.2.0/tests/test_workflows.py +159 -0
  28. sendly_python-0.1.0/CHANGELOG.md +0 -29
  29. sendly_python-0.1.0/src/sendly/errors.py +0 -92
  30. sendly_python-0.1.0/src/sendly/resources/events.py +0 -26
  31. sendly_python-0.1.0/tests/fixtures/openapi.json +0 -5729
  32. sendly_python-0.1.0/tests/test_events.py +0 -63
  33. {sendly_python-0.1.0 → sendly_python-0.2.0}/.github/workflows/ci.yml +0 -0
  34. {sendly_python-0.1.0 → sendly_python-0.2.0}/.github/workflows/release.yml +0 -0
  35. {sendly_python-0.1.0 → sendly_python-0.2.0}/.gitignore +0 -0
  36. {sendly_python-0.1.0 → sendly_python-0.2.0}/LICENSE +0 -0
  37. {sendly_python-0.1.0 → sendly_python-0.2.0}/scripts/sync_spec.py +0 -0
  38. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/py.typed +0 -0
  39. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/__init__.py +0 -0
  40. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/_helpers.py +0 -0
  41. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/contacts.py +0 -0
  42. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/domains.py +0 -0
  43. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/emails.py +0 -0
  44. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/suppression.py +0 -0
  45. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/templates.py +0 -0
  46. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/verify.py +0 -0
  47. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/resources/webhooks.py +0 -0
  48. {sendly_python-0.1.0 → sendly_python-0.2.0}/src/sendly/webhook_utils.py +0 -0
  49. {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_client.py +0 -0
  50. {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_contacts.py +0 -0
  51. {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_domains.py +0 -0
  52. {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_emails.py +0 -0
  53. {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_suppression.py +0 -0
  54. {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_templates.py +0 -0
  55. {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_verify.py +0 -0
  56. {sendly_python-0.1.0 → sendly_python-0.2.0}/tests/test_webhook_verify.py +0 -0
  57. {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.1.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.1.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 SendlyConnectionError, SendlyError, error_from_response
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.1.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
- (``emails``, ``contacts``, ``events``, ``domains``, ``templates``,
61
- ``verify``, ``webhooks``, ``suppression``) for all calls.
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(response.status_code, parsed)
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")