sendly-python 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -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",
sendly/client.py CHANGED
@@ -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")
sendly/errors.py CHANGED
@@ -3,30 +3,63 @@
3
3
  Mirrors the TypeScript SDK's ``errors.ts``: a single :class:`SendlyError` base with
4
4
  one subclass per meaningful HTTP status so callers can ``except`` a narrow type
5
5
  without inspecting the response body.
6
+
7
+ The API speaks two error dialects and both land on the same exception classes:
8
+
9
+ * legacy ``/api/*`` — ``{success: false, error: {code, message}}``;
10
+ * ``/api/v1/*`` — an RFC 9457 problem document served as
11
+ ``application/problem+json``. Its ``code`` becomes :attr:`SendlyError.error_code`
12
+ and its ``detail`` (falling back to ``title``) becomes the message, so
13
+ ``except SendlyValidationError`` behaves identically across both surfaces.
14
+ Two problem-only fields are surfaced additively: :attr:`SendlyError.request_id`
15
+ and :attr:`SendlyError.field_errors`.
6
16
  """
7
17
 
8
18
  from __future__ import annotations
9
19
 
10
20
  from typing import Any
11
21
 
22
+ #: Media type of an RFC 9457 problem document.
23
+ PROBLEM_CONTENT_TYPE = "application/problem+json"
24
+
12
25
 
13
26
  class SendlyError(Exception):
14
27
  """Base error for any non-2xx HTTP response or transport failure.
15
28
 
16
29
  Attributes:
17
30
  status_code: HTTP status (``0`` for client-side/transport failures).
18
- error_code: Machine-readable code from the API error envelope, or a
19
- synthesized ``http_<status>`` / ``invalid_response`` / ``connection_error``.
31
+ error_code: Machine-readable code from the API error envelope (legacy
32
+ ``error.code`` or v1 problem ``code``), or a synthesized
33
+ ``http_<status>`` / ``invalid_response`` / ``connection_error``.
20
34
  message: Human-readable message.
21
- body: The parsed (or raw) response body, when available.
35
+ body: The parsed (or raw) response body, when available. For a v1
36
+ failure this is the whole problem document, so ``type``, ``title``,
37
+ ``instance`` and any other member stays reachable.
38
+ request_id: Correlation id from a v1 problem document (``request_id``);
39
+ ``None`` on the legacy surface. Quote it in support requests.
40
+ field_errors: Field-level failures from a v1 ``validation_error``
41
+ problem (``errors``), each ``{pointer, code, message}``; ``None``
42
+ when the response carried none. The legacy surface puts its own
43
+ breakdown at ``body["error"]["details"]["errors"]`` instead.
22
44
  """
23
45
 
24
- def __init__(self, status_code: int, error_code: str, message: str, body: Any = None) -> None:
46
+ def __init__(
47
+ self,
48
+ status_code: int,
49
+ error_code: str,
50
+ message: str,
51
+ body: Any = None,
52
+ *,
53
+ request_id: str | None = None,
54
+ field_errors: list[dict[str, Any]] | None = None,
55
+ ) -> None:
25
56
  super().__init__(message)
26
57
  self.status_code = status_code
27
58
  self.error_code = error_code
28
59
  self.message = message
29
60
  self.body = body
61
+ self.request_id = request_id
62
+ self.field_errors = field_errors
30
63
 
31
64
 
32
65
  class SendlyValidationError(SendlyError):
@@ -69,24 +102,95 @@ class SendlyConnectionError(SendlyError):
69
102
  super().__init__(0, "connection_error", message, body)
70
103
 
71
104
 
72
- def error_from_response(
73
- status_code: int, error_code: str, message: str, body: Any = None
74
- ) -> SendlyError:
75
- """Map an HTTP status + error envelope to the appropriate error subclass."""
76
- if status_code == 400:
77
- return SendlyValidationError(status_code, error_code, message, body)
105
+ def _error_class(status_code: int) -> type[SendlyError]:
106
+ """The exception class a status maps to. Shared by both error dialects."""
107
+ if status_code in (400, 422):
108
+ return SendlyValidationError
78
109
  if status_code == 401:
79
- return SendlyAuthenticationError(status_code, error_code, message, body)
110
+ return SendlyAuthenticationError
80
111
  if status_code == 403:
81
- return SendlyPermissionError(status_code, error_code, message, body)
112
+ return SendlyPermissionError
82
113
  if status_code == 404:
83
- return SendlyNotFoundError(status_code, error_code, message, body)
84
- if status_code == 422:
85
- return SendlyValidationError(status_code, error_code, message, body)
114
+ return SendlyNotFoundError
86
115
  if status_code == 409:
87
- return SendlyConflictError(status_code, error_code, message, body)
116
+ return SendlyConflictError
88
117
  if status_code == 429:
89
- return SendlyRateLimitError(status_code, error_code, message, body)
118
+ return SendlyRateLimitError
90
119
  if status_code >= 500:
91
- return SendlyServerError(status_code, error_code, message, body)
92
- return SendlyError(status_code, error_code, message, body)
120
+ return SendlyServerError
121
+ return SendlyError
122
+
123
+
124
+ def error_from_response(
125
+ status_code: int,
126
+ error_code: str,
127
+ message: str,
128
+ body: Any = None,
129
+ *,
130
+ request_id: str | None = None,
131
+ field_errors: list[dict[str, Any]] | None = None,
132
+ ) -> SendlyError:
133
+ """Map an HTTP status + error envelope to the appropriate error subclass."""
134
+ return _error_class(status_code)(
135
+ status_code,
136
+ error_code,
137
+ message,
138
+ body,
139
+ request_id=request_id,
140
+ field_errors=field_errors,
141
+ )
142
+
143
+
144
+ def is_problem_document(body: Any, content_type: str | None = None) -> bool:
145
+ """Is this response body an RFC 9457 problem document?
146
+
147
+ Trusts the ``application/problem+json`` content type when present, and
148
+ otherwise falls back to the document shape (``type`` + ``title`` + ``code``),
149
+ so a proxy that rewrites the media type cannot downgrade a v1 error into the
150
+ generic ``http_<status>`` path. The legacy ``{success, error}`` envelope
151
+ carries none of those members, so it can never match.
152
+ """
153
+ if not isinstance(body, dict):
154
+ return False
155
+ if content_type and PROBLEM_CONTENT_TYPE in content_type.lower():
156
+ return True
157
+ return all(isinstance(body.get(key), str) for key in ("type", "title", "code"))
158
+
159
+
160
+ def error_from_problem(status_code: int, problem: dict[str, Any]) -> SendlyError:
161
+ """Map an RFC 9457 problem document to the exception class for its status.
162
+
163
+ ``code`` supplies the machine-readable :attr:`SendlyError.error_code` and
164
+ ``detail`` the message, falling back to ``title`` — a problem document always
165
+ carries a title but only sometimes an occurrence-specific detail.
166
+ """
167
+ raw_code = problem.get("code")
168
+ code = raw_code if isinstance(raw_code, str) and raw_code else f"http_{status_code}"
169
+
170
+ message = ""
171
+ for key in ("detail", "title"):
172
+ value = problem.get(key)
173
+ if isinstance(value, str) and value:
174
+ message = value
175
+ break
176
+ if not message:
177
+ message = f"Sendly request failed with status {status_code}"
178
+
179
+ raw_request_id = problem.get("request_id")
180
+ request_id = raw_request_id if isinstance(raw_request_id, str) else None
181
+
182
+ raw_errors = problem.get("errors")
183
+ field_errors = (
184
+ [item for item in raw_errors if isinstance(item, dict)]
185
+ if isinstance(raw_errors, list)
186
+ else None
187
+ )
188
+
189
+ return error_from_response(
190
+ status_code,
191
+ code,
192
+ message,
193
+ problem,
194
+ request_id=request_id,
195
+ field_errors=field_errors,
196
+ )
@@ -0,0 +1,49 @@
1
+ """Auto-pagination over the ``/api/v1`` cursor envelope.
2
+
3
+ Every v1 list endpoint answers with ``{data, has_more, next_cursor}``: an opaque
4
+ forward-only cursor and no total (deliberately — counting a project's rows is a
5
+ scan the API refuses to pay for on every page). :func:`iterate_cursor` walks that
6
+ shape so callers can treat a multi-page listing as one stream of items.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable, Iterator
15
+
16
+ from sendly.types import JSONDict, Query
17
+
18
+
19
+ def iterate_cursor(
20
+ fetch: Callable[[dict[str, Any]], Any],
21
+ query: Query | None = None,
22
+ ) -> Iterator[JSONDict]:
23
+ """Yield every item across the pages ``fetch`` returns.
24
+
25
+ ``fetch`` receives the query for one page — the caller's own filters, plus
26
+ the ``after`` cursor from page two onward — and returns the raw cursor
27
+ envelope. Iteration stops when ``has_more`` is false or ``next_cursor`` is
28
+ ``None``, and also when a page carries no ``data`` list, so a malformed
29
+ response ends the walk instead of looping forever.
30
+
31
+ The caller's filters are held fixed for the whole walk on purpose: changing
32
+ them mid-pagination invalidates the cursor and the API answers ``422
33
+ validation_error`` telling you to restart from the first page.
34
+ """
35
+ params = dict(query or {})
36
+ while True:
37
+ page = fetch(params)
38
+ if not isinstance(page, dict):
39
+ return
40
+ items = page.get("data")
41
+ if not isinstance(items, list):
42
+ return
43
+ yield from items
44
+ if not page.get("has_more"):
45
+ return
46
+ cursor = page.get("next_cursor")
47
+ if not cursor:
48
+ return
49
+ params = {**params, "after": cursor}
@@ -0,0 +1,49 @@
1
+ """Analytics resource (``/api/v1``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from sendly.client import Sendly
9
+ from sendly.types import (
10
+ AnalyticsTimeseries,
11
+ CampaignAnalytics,
12
+ Query,
13
+ TopCampaignList,
14
+ )
15
+
16
+
17
+ class AnalyticsResource:
18
+ """Aggregate sending and engagement metrics.
19
+
20
+ Every method takes an optional ``from`` / ``to`` window (ISO 8601) and echoes
21
+ the resolved window back as ``window``, so a caller can tell what the API
22
+ actually measured when it defaulted the range. None of these are
23
+ cursor-paginated — they answer a bounded aggregate, not a listing — so there
24
+ are no iterators here.
25
+ """
26
+
27
+ def __init__(self, client: Sendly) -> None:
28
+ self._client = client
29
+
30
+ def timeseries(self, query: Query | None = None) -> AnalyticsTimeseries:
31
+ """Per-day sending and engagement counts over the window."""
32
+ response: AnalyticsTimeseries = self._client.request(
33
+ method="GET", path="/api/v1/analytics/timeseries", query=query
34
+ )
35
+ return response
36
+
37
+ def campaigns(self, query: Query | None = None) -> CampaignAnalytics:
38
+ """Campaign totals for the window: counts plus average open/click rates."""
39
+ response: CampaignAnalytics = self._client.request(
40
+ method="GET", path="/api/v1/analytics/campaigns", query=query
41
+ )
42
+ return response
43
+
44
+ def top_campaigns(self, query: Query | None = None) -> TopCampaignList:
45
+ """Best-performing campaigns in the window. Accepts ``limit``."""
46
+ response: TopCampaignList = self._client.request(
47
+ method="GET", path="/api/v1/analytics/top-campaigns", query=query
48
+ )
49
+ return response
@@ -0,0 +1,135 @@
1
+ """Campaigns resource (``/api/v1``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sendly.resources._helpers import encode_path_segment, idempotency_headers
8
+ from sendly.resources._pagination import iterate_cursor
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Iterator
12
+
13
+ from sendly.client import Sendly
14
+ from sendly.types import (
15
+ Body,
16
+ CampaignDeleted,
17
+ CampaignList,
18
+ CampaignRecord,
19
+ CampaignStats,
20
+ JSONDict,
21
+ Query,
22
+ )
23
+
24
+
25
+ class CampaignsResource:
26
+ """Create, schedule, and run bulk email campaigns.
27
+
28
+ A campaign moves through ``DRAFT`` -> ``SENDING`` -> ``SENT``; :meth:`send`
29
+ starts it (optionally at a future time), and :meth:`pause` / :meth:`resume` /
30
+ :meth:`cancel` steer it while in flight. Responses are bare v1 resource
31
+ bodies — there is no ``{success, data}`` envelope to unwrap.
32
+ """
33
+
34
+ def __init__(self, client: Sendly) -> None:
35
+ self._client = client
36
+
37
+ def list(self, query: Query | None = None) -> CampaignList:
38
+ """List campaigns, newest first.
39
+
40
+ Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and
41
+ answers ``{data, has_more, next_cursor}``. Keep the filters identical for
42
+ every page of one walk — changing them invalidates the cursor and the API
43
+ answers 422 ``validation_error`` telling you to restart from the first
44
+ page. :meth:`iter_list` does that bookkeeping for you.
45
+ """
46
+ response: CampaignList = self._client.request(
47
+ method="GET", path="/api/v1/campaigns", query=query
48
+ )
49
+ return response
50
+
51
+ def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]:
52
+ """Iterate every campaign across pages, following the cursor for you."""
53
+ return iterate_cursor(self.list, query)
54
+
55
+ def create(self, body: Body, *, idempotency_key: str | None = None) -> CampaignRecord:
56
+ """Create a campaign in ``DRAFT``.
57
+
58
+ Requires ``name``, ``subject``, ``body``, ``from`` and ``audience_type``.
59
+ Pass ``idempotency_key`` (1-255 chars) to make a replayed create return
60
+ the original campaign instead of a second one.
61
+ """
62
+ response: CampaignRecord = self._client.request(
63
+ method="POST",
64
+ path="/api/v1/campaigns",
65
+ body=body,
66
+ headers=idempotency_headers(idempotency_key),
67
+ )
68
+ return response
69
+
70
+ def get(self, id: str) -> CampaignRecord:
71
+ """Fetch a single campaign, including its delivery ``stats``."""
72
+ response: CampaignRecord = self._client.request(
73
+ method="GET", path=f"/api/v1/campaigns/{encode_path_segment(id)}"
74
+ )
75
+ return response
76
+
77
+ def update(self, id: str, body: Body) -> CampaignRecord:
78
+ """Patch a draft campaign's content or audience."""
79
+ response: CampaignRecord = self._client.request(
80
+ method="PATCH", path=f"/api/v1/campaigns/{encode_path_segment(id)}", body=body
81
+ )
82
+ return response
83
+
84
+ def delete(self, id: str) -> CampaignDeleted:
85
+ """Delete a campaign. Returns the ``{id, deleted}`` confirmation body."""
86
+ response: CampaignDeleted = self._client.request(
87
+ method="DELETE", path=f"/api/v1/campaigns/{encode_path_segment(id)}"
88
+ )
89
+ return response
90
+
91
+ def send(
92
+ self, id: str, body: Body | None = None, *, idempotency_key: str | None = None
93
+ ) -> CampaignRecord:
94
+ """Send a campaign now, or schedule it.
95
+
96
+ Pass ``{"scheduled_for": "<ISO 8601>"}`` as ``body`` to queue it for a
97
+ future time instead of sending immediately. ``idempotency_key`` is the
98
+ guard that matters most on this call: a replayed send must not mail the
99
+ audience twice.
100
+ """
101
+ response: CampaignRecord = self._client.request(
102
+ method="POST",
103
+ path=f"/api/v1/campaigns/{encode_path_segment(id)}/send",
104
+ body=body,
105
+ headers=idempotency_headers(idempotency_key),
106
+ )
107
+ return response
108
+
109
+ def cancel(self, id: str) -> CampaignRecord:
110
+ """Cancel a scheduled or in-flight campaign. Already-sent mail stays sent."""
111
+ response: CampaignRecord = self._client.request(
112
+ method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/cancel"
113
+ )
114
+ return response
115
+
116
+ def pause(self, id: str) -> CampaignRecord:
117
+ """Pause an in-flight campaign, holding the remaining recipients."""
118
+ response: CampaignRecord = self._client.request(
119
+ method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/pause"
120
+ )
121
+ return response
122
+
123
+ def resume(self, id: str) -> CampaignRecord:
124
+ """Resume a paused campaign from where it stopped."""
125
+ response: CampaignRecord = self._client.request(
126
+ method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/resume"
127
+ )
128
+ return response
129
+
130
+ def stats(self, id: str) -> CampaignStats:
131
+ """Delivery and engagement counters plus derived rates for one campaign."""
132
+ response: CampaignStats = self._client.request(
133
+ method="GET", path=f"/api/v1/campaigns/{encode_path_segment(id)}/stats"
134
+ )
135
+ return response
@@ -1,22 +1,43 @@
1
- """Events resource."""
1
+ """Events resource (legacy ``/api/track`` + the ``/api/v1/events`` surface)."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
+ from sendly.resources._pagination import iterate_cursor
8
+
7
9
  if TYPE_CHECKING:
10
+ from collections.abc import Iterator
11
+
8
12
  from sendly.client import Sendly
9
- from sendly.types import Body, TrackEventData
13
+ from sendly.types import (
14
+ Body,
15
+ EventList,
16
+ EventNameList,
17
+ EventRecord,
18
+ EventStats,
19
+ JSONDict,
20
+ Query,
21
+ TrackEventData,
22
+ )
10
23
 
11
24
 
12
25
  class EventsResource:
13
- """Record custom events for contacts."""
26
+ """Record custom events for contacts, and query the ones already recorded.
27
+
28
+ Two write methods, one per API surface. :meth:`track` is the original
29
+ ``POST /api/track`` call and is unchanged; :meth:`record` is its ``/api/v1``
30
+ counterpart. They do the same thing — the difference is the dialect: v1
31
+ returns the event body directly and reports failures as RFC 9457 problem
32
+ documents. New code should prefer :meth:`record`, alongside the v1 read
33
+ methods below.
34
+ """
14
35
 
15
36
  def __init__(self, client: Sendly) -> None:
16
37
  self._client = client
17
38
 
18
39
  def track(self, body: Body) -> TrackEventData:
19
- """Record a custom event for a contact.
40
+ """Record a custom event for a contact (legacy ``/api/track``).
20
41
 
21
42
  Both full (``sk_*``) and sending-only (``pk_*``) keys are accepted.
22
43
  Reserved system event names (e.g. ``email.sent``) are rejected by the API.
@@ -24,3 +45,54 @@ class EventsResource:
24
45
  envelope = self._client.request(method="POST", path="/api/track", body=body)
25
46
  data: TrackEventData = self._client.unwrap(envelope)
26
47
  return data
48
+
49
+ def record(self, body: Body) -> EventRecord:
50
+ """Record a custom event for a contact (``/api/v1``).
51
+
52
+ Requires ``name``; optionally takes ``contact_id`` and a ``data`` object.
53
+ The v1 counterpart of :meth:`track`, returning the created event body
54
+ rather than a ``{success, data}`` envelope.
55
+
56
+ Takes no ``Idempotency-Key``: events are the highest-volume write on the
57
+ surface and append-only by nature, so the API deliberately does not
58
+ ledger them. If a duplicate would matter to you, dedupe on your side.
59
+ """
60
+ response: EventRecord = self._client.request(
61
+ method="POST", path="/api/v1/events", body=body
62
+ )
63
+ return response
64
+
65
+ def list(self, query: Query | None = None) -> EventList:
66
+ """List recorded events, newest first.
67
+
68
+ Accepts ``limit`` (1-100, default 20), ``after`` (opaque cursor), and
69
+ ``event_name`` to filter to one event. Answers
70
+ ``{data, has_more, next_cursor}`` — no total, deliberately. Keep the
71
+ filters identical across one walk: changing them invalidates the cursor
72
+ and the API answers 422 ``validation_error`` telling you to restart from
73
+ the first page.
74
+ """
75
+ response: EventList = self._client.request(method="GET", path="/api/v1/events", query=query)
76
+ return response
77
+
78
+ def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]:
79
+ """Iterate every matching event across pages, following the cursor for you."""
80
+ return iterate_cursor(self.list, query)
81
+
82
+ def list_names(self) -> EventNameList:
83
+ """The distinct event names recorded on the project.
84
+
85
+ Takes no arguments — the endpoint declares no parameters, and the answer
86
+ is the project's whole name set. Useful for building a workflow trigger:
87
+ a workflow's ``event_name`` has to match a name events are actually
88
+ recorded under.
89
+ """
90
+ response: EventNameList = self._client.request(method="GET", path="/api/v1/events/names")
91
+ return response
92
+
93
+ def stats(self, query: Query | None = None) -> EventStats:
94
+ """Per-event counts over an optional ``from`` / ``to`` window."""
95
+ response: EventStats = self._client.request(
96
+ method="GET", path="/api/v1/events/stats", query=query
97
+ )
98
+ return response
@@ -0,0 +1,59 @@
1
+ """Lists resource (subscribe / unsubscribe)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sendly.resources._helpers import encode_path_segment
8
+
9
+ if TYPE_CHECKING:
10
+ from sendly.client import Sendly
11
+ from sendly.types import Body, ListSubscribeData, ListUnsubscribeData
12
+
13
+
14
+ class ListsResource:
15
+ """Manage a contact's membership on a subscriber list.
16
+
17
+ Both calls accept sending-only (``pk_*``) keys so they can back a public
18
+ subscribe or preference form directly.
19
+ """
20
+
21
+ def __init__(self, client: Sendly) -> None:
22
+ self._client = client
23
+
24
+ def subscribe(self, id: str, body: Body) -> ListSubscribeData:
25
+ """Subscribe an address to a list, creating the contact if needed.
26
+
27
+ Requires ``email``. Two behaviours worth knowing before you wire this to
28
+ a form:
29
+
30
+ * When the list has double opt-in, the membership is created ``PENDING``
31
+ and the response carries a ``confirmToken``. Sendly does **not** send
32
+ the confirmation email — deliver ``/api/lists/confirm?token=<token>``
33
+ to the contact yourself.
34
+ * Re-subscribing an address that previously opted out fails with
35
+ ``409 RESUBSCRIBE_CONFIRMATION_REQUIRED`` unless the body sets
36
+ ``allowResubscribe: true``. Read ``previousStatus`` rather than
37
+ ``created`` to describe the transition back to the user.
38
+ """
39
+ envelope = self._client.request(
40
+ method="POST",
41
+ path=f"/api/lists/{encode_path_segment(id)}/subscribe",
42
+ body=body,
43
+ )
44
+ data: ListSubscribeData = self._client.unwrap(envelope)
45
+ return data
46
+
47
+ def unsubscribe(self, id: str, body: Body) -> ListUnsubscribeData:
48
+ """Mark an address's membership on this list ``UNSUBSCRIBED``.
49
+
50
+ Requires ``email``. Idempotent — unsubscribing an address that is not a
51
+ member succeeds.
52
+ """
53
+ envelope = self._client.request(
54
+ method="POST",
55
+ path=f"/api/lists/{encode_path_segment(id)}/unsubscribe",
56
+ body=body,
57
+ )
58
+ data: ListUnsubscribeData = self._client.unwrap(envelope)
59
+ return data
@@ -0,0 +1,106 @@
1
+ """Segments resource (``/api/v1``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sendly.resources._helpers import encode_path_segment
8
+ from sendly.resources._pagination import iterate_cursor
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Iterator
12
+
13
+ from sendly.client import Sendly
14
+ from sendly.types import (
15
+ Body,
16
+ ContactList,
17
+ JSONDict,
18
+ Query,
19
+ SegmentDeleted,
20
+ SegmentList,
21
+ SegmentRecord,
22
+ )
23
+
24
+
25
+ class SegmentsResource:
26
+ """Group contacts into static lists or dynamic, condition-driven audiences.
27
+
28
+ A ``DYNAMIC`` segment's ``condition`` is evaluated by the API — its
29
+ ``member_count`` is computed at creation and kept current — so an invalid
30
+ condition fails the create with a 422 rather than silently matching nothing.
31
+ """
32
+
33
+ def __init__(self, client: Sendly) -> None:
34
+ self._client = client
35
+
36
+ def list(self, query: Query | None = None) -> SegmentList:
37
+ """List segments.
38
+
39
+ Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and
40
+ answers ``{data, has_more, next_cursor}``. Hold the filters steady across
41
+ one walk — changing them mid-pagination invalidates the cursor and the
42
+ API answers 422 ``validation_error`` telling you to restart from the
43
+ first page.
44
+ """
45
+ response: SegmentList = self._client.request(
46
+ method="GET", path="/api/v1/segments", query=query
47
+ )
48
+ return response
49
+
50
+ def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]:
51
+ """Iterate every segment across pages, following the cursor for you."""
52
+ return iterate_cursor(self.list, query)
53
+
54
+ def create(self, body: Body) -> SegmentRecord:
55
+ """Create a segment. Requires ``name``.
56
+
57
+ Takes no ``Idempotency-Key``: creating a segment neither sends anything
58
+ nor consumes quota, so a duplicate costs one row that a
59
+ :meth:`delete` undoes.
60
+ """
61
+ response: SegmentRecord = self._client.request(
62
+ method="POST", path="/api/v1/segments", body=body
63
+ )
64
+ return response
65
+
66
+ def get(self, id: str) -> SegmentRecord:
67
+ """Fetch a single segment, including its current ``member_count``."""
68
+ response: SegmentRecord = self._client.request(
69
+ method="GET", path=f"/api/v1/segments/{encode_path_segment(id)}"
70
+ )
71
+ return response
72
+
73
+ def update(self, id: str, body: Body) -> SegmentRecord:
74
+ """Patch a segment's name, description, condition, or membership tracking."""
75
+ response: SegmentRecord = self._client.request(
76
+ method="PATCH", path=f"/api/v1/segments/{encode_path_segment(id)}", body=body
77
+ )
78
+ return response
79
+
80
+ def delete(self, id: str) -> SegmentDeleted:
81
+ """Delete a segment. Returns the ``{id, deleted}`` confirmation body.
82
+
83
+ Removes the grouping, not the contacts in it.
84
+ """
85
+ response: SegmentDeleted = self._client.request(
86
+ method="DELETE", path=f"/api/v1/segments/{encode_path_segment(id)}"
87
+ )
88
+ return response
89
+
90
+ def list_contacts(self, id: str, query: Query | None = None) -> ContactList:
91
+ """List the contacts currently in a segment.
92
+
93
+ Cursor-paginated like :meth:`list` (``limit`` / ``after``). For a dynamic
94
+ segment this is evaluated against the live condition, so membership can
95
+ differ between two walks.
96
+ """
97
+ response: ContactList = self._client.request(
98
+ method="GET",
99
+ path=f"/api/v1/segments/{encode_path_segment(id)}/contacts",
100
+ query=query,
101
+ )
102
+ return response
103
+
104
+ def iter_list_contacts(self, id: str, query: Query | None = None) -> Iterator[JSONDict]:
105
+ """Iterate every contact in a segment across pages."""
106
+ return iterate_cursor(lambda params: self.list_contacts(id, params), query)
@@ -0,0 +1,27 @@
1
+ """Usage resource (``/api/v1``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from sendly.client import Sendly
9
+ from sendly.types import UsageSummary
10
+
11
+
12
+ class UsageResource:
13
+ """The caller's plan and its current quota consumption."""
14
+
15
+ def __init__(self, client: Sendly) -> None:
16
+ self._client = client
17
+
18
+ def get(self) -> UsageSummary:
19
+ """Current ``plan`` plus ``monthly`` and ``daily`` usage against its limits.
20
+
21
+ Read this before a large send to see the headroom the API would enforce:
22
+ exceeding a quota answers 429 ``quota_exhausted``, which is a different
23
+ failure from 429 ``rate_limited`` (too fast, retry) and is not fixed by
24
+ backing off.
25
+ """
26
+ response: UsageSummary = self._client.request(method="GET", path="/api/v1/usage")
27
+ return response
@@ -0,0 +1,139 @@
1
+ """Workflows resource (``/api/v1``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sendly.resources._helpers import encode_path_segment
8
+ from sendly.resources._pagination import iterate_cursor
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Iterator
12
+
13
+ from sendly.client import Sendly
14
+ from sendly.types import (
15
+ Body,
16
+ JSONDict,
17
+ Query,
18
+ WorkflowDeleted,
19
+ WorkflowExecutionList,
20
+ WorkflowExecutionRecord,
21
+ WorkflowList,
22
+ WorkflowRecord,
23
+ WorkflowStats,
24
+ )
25
+
26
+
27
+ class WorkflowsResource:
28
+ """Event-triggered automations and their per-contact executions.
29
+
30
+ A workflow fires when its ``event_name`` arrives for a contact (see
31
+ :meth:`~sendly.resources.events.EventsResource.record`); ``allow_reentry``
32
+ decides whether a contact already running the workflow can start it again.
33
+ """
34
+
35
+ def __init__(self, client: Sendly) -> None:
36
+ self._client = client
37
+
38
+ def list(self, query: Query | None = None) -> WorkflowList:
39
+ """List workflows.
40
+
41
+ Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and
42
+ answers ``{data, has_more, next_cursor}``. Keep the filters identical for
43
+ every page of one walk — changing them invalidates the cursor and the API
44
+ answers 422 ``validation_error`` telling you to restart from the first
45
+ page.
46
+ """
47
+ response: WorkflowList = self._client.request(
48
+ method="GET", path="/api/v1/workflows", query=query
49
+ )
50
+ return response
51
+
52
+ def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]:
53
+ """Iterate every workflow across pages, following the cursor for you."""
54
+ return iterate_cursor(self.list, query)
55
+
56
+ def create(self, body: Body) -> WorkflowRecord:
57
+ """Create a workflow. Requires ``name`` and ``event_name``."""
58
+ response: WorkflowRecord = self._client.request(
59
+ method="POST", path="/api/v1/workflows", body=body
60
+ )
61
+ return response
62
+
63
+ def get(self, id: str) -> WorkflowRecord:
64
+ """Fetch a single workflow."""
65
+ response: WorkflowRecord = self._client.request(
66
+ method="GET", path=f"/api/v1/workflows/{encode_path_segment(id)}"
67
+ )
68
+ return response
69
+
70
+ def update(self, id: str, body: Body) -> WorkflowRecord:
71
+ """Patch a workflow — including ``enabled``, which is how you pause one."""
72
+ response: WorkflowRecord = self._client.request(
73
+ method="PATCH", path=f"/api/v1/workflows/{encode_path_segment(id)}", body=body
74
+ )
75
+ return response
76
+
77
+ def delete(self, id: str) -> WorkflowDeleted:
78
+ """Delete a workflow. Returns the ``{id, deleted}`` confirmation body."""
79
+ response: WorkflowDeleted = self._client.request(
80
+ method="DELETE", path=f"/api/v1/workflows/{encode_path_segment(id)}"
81
+ )
82
+ return response
83
+
84
+ def list_executions(self, id: str, query: Query | None = None) -> WorkflowExecutionList:
85
+ """List one workflow's executions.
86
+
87
+ Cursor-paginated (``limit`` / ``after``) and filterable by ``status``.
88
+ As with every v1 listing, a filter you change mid-walk invalidates the
89
+ cursor — restart from the first page instead.
90
+ """
91
+ response: WorkflowExecutionList = self._client.request(
92
+ method="GET",
93
+ path=f"/api/v1/workflows/{encode_path_segment(id)}/executions",
94
+ query=query,
95
+ )
96
+ return response
97
+
98
+ def iter_list_executions(self, id: str, query: Query | None = None) -> Iterator[JSONDict]:
99
+ """Iterate every execution of a workflow across pages."""
100
+ return iterate_cursor(lambda params: self.list_executions(id, params), query)
101
+
102
+ def start_execution(self, id: str, body: Body) -> WorkflowExecutionRecord:
103
+ """Start the workflow for one contact, bypassing its event trigger.
104
+
105
+ ``body`` is required and must carry ``contact_id`` — an execution always
106
+ belongs to a contact. It may also carry a ``context`` object the
107
+ workflow's steps can read.
108
+ """
109
+ response: WorkflowExecutionRecord = self._client.request(
110
+ method="POST",
111
+ path=f"/api/v1/workflows/{encode_path_segment(id)}/executions",
112
+ body=body,
113
+ )
114
+ return response
115
+
116
+ def cancel_execution(self, execution_id: str) -> WorkflowExecutionRecord:
117
+ """Cancel one in-flight execution.
118
+
119
+ Addressed by execution id alone — the route is
120
+ ``/api/v1/workflows/executions/{execution_id}/cancel``, not nested under
121
+ the workflow — so a caller holding an execution id needs nothing else.
122
+ """
123
+ response: WorkflowExecutionRecord = self._client.request(
124
+ method="POST",
125
+ path=f"/api/v1/workflows/executions/{encode_path_segment(execution_id)}/cancel",
126
+ )
127
+ return response
128
+
129
+ def stats(self, id: str, query: Query | None = None) -> WorkflowStats:
130
+ """Execution totals, completion rate, and attributed email/conversion counts.
131
+
132
+ Accepts ``from`` to bound the window.
133
+ """
134
+ response: WorkflowStats = self._client.request(
135
+ method="GET",
136
+ path=f"/api/v1/workflows/{encode_path_segment(id)}/stats",
137
+ query=query,
138
+ )
139
+ return response
sendly/types.py CHANGED
@@ -73,6 +73,11 @@ SuppressionRecord = JSONDict
73
73
  SuppressionListResponse = JSONDict
74
74
  SuppressionCheckResponse = JSONDict
75
75
 
76
+ # ---------- Lists ----------
77
+
78
+ ListSubscribeData = JSONDict
79
+ ListUnsubscribeData = JSONDict
80
+
76
81
  # ---------- Events ----------
77
82
 
78
83
  TrackEventData = JSONDict
@@ -82,3 +87,39 @@ TrackEventResponse = JSONDict
82
87
 
83
88
  VerifyEmailData = JSONDict
84
89
  VerifyEmailResponse = JSONDict
90
+
91
+ # ---------- /api/v1 ----------
92
+ #
93
+ # The v1 surface returns bare resource bodies (no {success, data} envelope) and
94
+ # a uniform list envelope: {data, has_more, next_cursor}. The ``*List`` aliases
95
+ # below name that envelope; the iterator methods yield the items inside ``data``.
96
+
97
+ CursorList = JSONDict
98
+
99
+ CampaignRecord = JSONDict
100
+ CampaignList = CursorList
101
+ CampaignStats = JSONDict
102
+ CampaignDeleted = JSONDict
103
+
104
+ SegmentRecord = JSONDict
105
+ SegmentList = CursorList
106
+ SegmentDeleted = JSONDict
107
+ ContactList = CursorList
108
+
109
+ WorkflowRecord = JSONDict
110
+ WorkflowList = CursorList
111
+ WorkflowDeleted = JSONDict
112
+ WorkflowStats = JSONDict
113
+ WorkflowExecutionRecord = JSONDict
114
+ WorkflowExecutionList = CursorList
115
+
116
+ EventRecord = JSONDict
117
+ EventList = CursorList
118
+ EventNameList = JSONDict
119
+ EventStats = JSONDict
120
+
121
+ AnalyticsTimeseries = JSONDict
122
+ CampaignAnalytics = JSONDict
123
+ TopCampaignList = JSONDict
124
+
125
+ UsageSummary = JSONDict
@@ -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
@@ -0,0 +1,27 @@
1
+ sendly/__init__.py,sha256=AQ1Yu-7DFJ9LQz_-w6RSk_u25U-oITJGwRwJ1M9v-9s,2487
2
+ sendly/client.py,sha256=GRII4hPJwBNPDPL9i84y3YwtPvRgT5CVXGkBgL1r7IY,11356
3
+ sendly/errors.py,sha256=urF33X-d39OEiasLh6C7PlrrUmmrS3XIMvFYj20rUy8,7002
4
+ sendly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ sendly/types.py,sha256=ggxBcjjkdgmJEPNk04tFdrLoTggOFZuj2kcqsvBPIXk,3140
6
+ sendly/webhook_utils.py,sha256=epmK860gLknaElxfXdVD3h_9ASkjWQsyl1exUC8qBL4,4762
7
+ sendly/resources/__init__.py,sha256=Nf7zjcFjsztXsGtrvxq3KGacX7SwDgSf3r0nAVHLvPY,73
8
+ sendly/resources/_helpers.py,sha256=W6AlzXuqH53Vk1dIxuQm_-Vpq9gmZvLzKPRionjH9wQ,550
9
+ sendly/resources/_pagination.py,sha256=dp0rNKruPBtCgPZT8ZPzmGGVpbFN2SCQdV4m9-qlLEA,1786
10
+ sendly/resources/analytics.py,sha256=7w4A-w-dXZkgLi2j1dO6X88V-T9itlF0rWjdLiFAStc,1770
11
+ sendly/resources/campaigns.py,sha256=vcpwNa51ncSuieGYUhwcMLpzB-cRrf6qZ1RXB0iVmo8,5293
12
+ sendly/resources/contacts.py,sha256=7DwbGXvwUw8Tf5WfIUU0ZEvJs3ZBIqulkbN_Ke0-ilc,3274
13
+ sendly/resources/domains.py,sha256=m69uHKGhmtVlzo3QAmMmMHvmpc0T3JAv5izTWxDpxhc,2368
14
+ sendly/resources/emails.py,sha256=3LHxre7lJfa_6z1F9Rzmdh5QDwhYpwTWaAJZGVCLsY8,2390
15
+ sendly/resources/events.py,sha256=8aFUPa1iTVqEUnhwnpcsfE7_JyirtBw7POgadk4hhMs,3926
16
+ sendly/resources/lists.py,sha256=KXHOW_UQMEdXoat9mHJ4mJKmvrzk0AVQ3k8273GRIH4,2189
17
+ sendly/resources/segments.py,sha256=dc4KzorLRSfkZe63J144wm6W5bjVaDA4U8ID7HyyK_U,3944
18
+ sendly/resources/suppression.py,sha256=NNIhmkq1bC2qe1i4nSGZDCvuGBT7JddIrkXlVP1qASU,1716
19
+ sendly/resources/templates.py,sha256=DhewoFRqxGfjo0brAtG9rI6GHTCWNv4J01TylB4luXI,2104
20
+ sendly/resources/usage.py,sha256=kMcoAeGAnCilLyNihKBFcfFf-CVoSlpKUmTtlfDWAMA,879
21
+ sendly/resources/verify.py,sha256=QyVNmrzZ4Nj87YWxMPt9wlovcCyZutV9eGmn49c0EEg,818
22
+ sendly/resources/webhooks.py,sha256=rooES4-HMy7mhC9D1bzVDUpJ34cf1yZkVilkoWEIBEQ,2763
23
+ sendly/resources/workflows.py,sha256=42j-227rFUkWjA6DYmKYxqc6Bfdpxy5IHDuDgZrUO5k,5381
24
+ sendly_python-0.2.0.dist-info/METADATA,sha256=qP01SKUw9EeFuqAMVvnnyPTMLrgs-nwZZJlU9lbAn2w,16456
25
+ sendly_python-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
26
+ sendly_python-0.2.0.dist-info/licenses/LICENSE,sha256=TZHT_f_vV-xujWjSy7EkTybNT6iivrfGHLd-rR1Xl7c,1073
27
+ sendly_python-0.2.0.dist-info/RECORD,,
@@ -1,20 +0,0 @@
1
- sendly/__init__.py,sha256=EK_NplzmfmE_d_CcW7epJdKbZLVFn5zK3culailQLkA,1764
2
- sendly/client.py,sha256=fiiRtDeV3Wt1nLA2ERsK-rlSNg9XBzj4wNgiUA-kYY0,9467
3
- sendly/errors.py,sha256=7nlE2ixqRXS21zYe5qwb-rTUyv1Wx3yI9uThsf4sJoQ,3394
4
- sendly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- sendly/types.py,sha256=YiHQaggce5KIEr5cZMg0Ks923mDX9kNUrpWLujvf00k,2159
6
- sendly/webhook_utils.py,sha256=epmK860gLknaElxfXdVD3h_9ASkjWQsyl1exUC8qBL4,4762
7
- sendly/resources/__init__.py,sha256=Nf7zjcFjsztXsGtrvxq3KGacX7SwDgSf3r0nAVHLvPY,73
8
- sendly/resources/_helpers.py,sha256=W6AlzXuqH53Vk1dIxuQm_-Vpq9gmZvLzKPRionjH9wQ,550
9
- sendly/resources/contacts.py,sha256=7DwbGXvwUw8Tf5WfIUU0ZEvJs3ZBIqulkbN_Ke0-ilc,3274
10
- sendly/resources/domains.py,sha256=m69uHKGhmtVlzo3QAmMmMHvmpc0T3JAv5izTWxDpxhc,2368
11
- sendly/resources/emails.py,sha256=3LHxre7lJfa_6z1F9Rzmdh5QDwhYpwTWaAJZGVCLsY8,2390
12
- sendly/resources/events.py,sha256=hEy1HCRWfPS_Mc7xxWZtgqj9R8iYsgPRR1YdZuGuP1c,785
13
- sendly/resources/suppression.py,sha256=NNIhmkq1bC2qe1i4nSGZDCvuGBT7JddIrkXlVP1qASU,1716
14
- sendly/resources/templates.py,sha256=DhewoFRqxGfjo0brAtG9rI6GHTCWNv4J01TylB4luXI,2104
15
- sendly/resources/verify.py,sha256=QyVNmrzZ4Nj87YWxMPt9wlovcCyZutV9eGmn49c0EEg,818
16
- sendly/resources/webhooks.py,sha256=rooES4-HMy7mhC9D1bzVDUpJ34cf1yZkVilkoWEIBEQ,2763
17
- sendly_python-0.1.0.dist-info/METADATA,sha256=_CFNo8me4v-Duk1M_PWpiiq_FKQNcfd3ASOh4jSN-IM,9560
18
- sendly_python-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
19
- sendly_python-0.1.0.dist-info/licenses/LICENSE,sha256=TZHT_f_vV-xujWjSy7EkTybNT6iivrfGHLd-rR1Xl7c,1073
20
- sendly_python-0.1.0.dist-info/RECORD,,