Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,50 @@ All notable changes to `sendly-python` are documented here. This project adheres

## [Unreleased]

## [0.2.0]

Adds Sendly's `/api/v1` surface. Purely additive — every existing method keeps
its name, signature, and behaviour.

### Added

- **New resources for the `/api/v1` surface**, wired onto the same client:
`sendly.campaigns`, `sendly.segments`, `sendly.workflows`, `sendly.analytics`
and `sendly.usage`, covering all 33 v1 operations. Unlike the legacy `/api/*`
resources, these return **bare resource bodies** — there is no
`{success, data}` envelope to unwrap.
- **v1 methods on the existing `events` resource**: `events.record` (the v1
counterpart of `events.track`, which is unchanged), `events.list`,
`events.list_names` and `events.stats`. `record` takes no `idempotency_key`:
events are append-only and the API deliberately does not ledger them.
- **Auto-pagination.** Each of the six cursor-paginated v1 listings gains an
`iter_*` companion yielding individual items and following the cursor for you:
`campaigns.iter_list`, `segments.iter_list`, `segments.iter_list_contacts`,
`workflows.iter_list`, `workflows.iter_list_executions`, `events.iter_list`.
The v1 list envelope is `{data, has_more, next_cursor}` with `limit` (1-100,
default 20) and `after` — no total, deliberately. Changing filters
mid-pagination invalidates the cursor and returns `422 validation_error`, so
the iterators hold the query fixed and only advance `after`.
- **RFC 9457 error support.** `application/problem+json` responses from `/api/v1`
map to the **same** exception classes as the legacy envelope, keyed off the
same statuses — existing `except` blocks are unaffected. The problem's `code`
becomes `err.error_code` (e.g. `scope_missing`, `quota_exhausted`,
`idempotency_key_reused`) and its `detail` (falling back to `title`) becomes
`err.message`. Two fields are new on `SendlyError`:
- `err.request_id` — correlation id from the problem document, `None` on the
legacy surface;
- `err.field_errors` — per-field `{pointer, code, message}` entries from a v1
`validation_error`, `None` when absent. The legacy per-field breakdown stays
at `err.body["error"]["details"]["errors"]`.
- **`sendly.lists`** — `lists.subscribe(id, body)` and
`lists.unsubscribe(id, body)` wrap the newly published
`POST /api/lists/{id}/subscribe` and `.../unsubscribe` operations. Both accept
sending-only (`pk_*`) keys so they can back a public form. On a double opt-in
list, subscribe returns `PENDING` with a `confirmToken` and Sendly does **not**
send the confirmation email — the caller delivers it. Re-subscribing an address
that opted out needs `allowResubscribe: true` or fails with
`409 RESUBSCRIBE_CONFIRMATION_REQUIRED`.

### Changed

- Re-synced the vendored OpenAPI spec (`tests/fixtures/openapi.json`) to the
Expand Down
153 changes: 153 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,125 @@ sendly.suppression.get("bounce@example.com")
sendly.suppression.remove("bounce@example.com")
```

### Lists

```python
# Both calls accept sending-only (pk_*) keys, so they can back a public form.
result = sendly.lists.subscribe("l_123", {"email": "user@example.com"})

# On a double opt-in list the membership is PENDING and carries a confirmToken.
# Sendly does NOT send the confirmation email — deliver this link yourself.
if result["status"] == "PENDING":
confirm_url = f"https://api.sendly.now/api/lists/confirm?token={result['confirmToken']}"

# Re-subscribing an address that opted out needs an explicit opt-in, or the call
# fails with 409 RESUBSCRIBE_CONFIRMATION_REQUIRED.
sendly.lists.subscribe("l_123", {"email": "user@example.com", "allowResubscribe": True})

sendly.lists.unsubscribe("l_123", {"email": "user@example.com"})
```

## The v1 API

`campaigns`, `segments`, `workflows`, `analytics` and `usage` — plus the v1
methods on `events` — speak Sendly's `/api/v1` surface. Same client, same API
key; two differences worth knowing:

- **Responses are bare resource bodies.** There is no `{success, data}` envelope
to unwrap, so what the API documents is exactly what you get.
- **Errors are RFC 9457 problem documents.** They raise the same exception
classes as the legacy surface, with two extra fields — see
[Error handling](#error-handling).

### Campaigns

```python
campaign = sendly.campaigns.create(
{
"name": "August launch",
"subject": "We are live",
"body": "<p>Hello</p>",
"from": "team@you.com",
"audience_type": "ALL",
},
idempotency_key="august-launch",
)

# Send now, or schedule it. Key the replay — a duplicate send mails the audience twice.
sendly.campaigns.send(campaign["id"], idempotency_key="august-launch-send")
sendly.campaigns.send(campaign["id"], {"scheduled_for": "2026-09-01T10:00:00Z"})

sendly.campaigns.pause(campaign["id"])
sendly.campaigns.resume(campaign["id"])
sendly.campaigns.cancel(campaign["id"])

stats = sendly.campaigns.stats(campaign["id"])
print(stats["delivered"], stats["open_rate"])
```

### Pagination

Every v1 list answers `{data, has_more, next_cursor}` — an opaque forward-only
cursor, and no total. Page it yourself with `limit` (1–100, default 20) and
`after`:

```python
page = sendly.campaigns.list({"limit": 50})
while page["has_more"]:
page = sendly.campaigns.list({"limit": 50, "after": page["next_cursor"]})
```

…or let the `iter_*` companion do it. It yields individual items and follows the
cursor until the last page:

```python
for campaign in sendly.campaigns.iter_list({"limit": 100}):
print(campaign["name"], campaign["status"])

for contact in sendly.segments.iter_list_contacts("seg_123"):
print(contact["email"])
```

Keep your filters identical for every page of one walk. Changing them
mid-pagination invalidates the cursor and the API answers `422 validation_error`
telling you to restart from the first page — which is exactly why `iter_*` holds
the query fixed and only advances `after`.

Available on the six cursor-paginated listings: `campaigns.iter_list`,
`segments.iter_list`, `segments.iter_list_contacts`, `workflows.iter_list`,
`workflows.iter_list_executions`, `events.iter_list`. The analytics endpoints and
`events.list_names` / `events.stats` return a bounded aggregate rather than a
cursor, so they have no iterator.

### Segments, workflows, events, analytics, usage

```python
segment = sendly.segments.create({"name": "Power users", "type": "DYNAMIC",
"condition": {"field": "plan", "op": "eq", "value": "pro"}})
sendly.segments.list_contacts(segment["id"], {"limit": 50})

workflow = sendly.workflows.create({"name": "Welcome", "event_name": "signup.completed"})
sendly.workflows.start_execution(workflow["id"], {"contact_id": "c_123"})
# Executions are cancelled by execution id alone — not nested under the workflow.
sendly.workflows.cancel_execution("exe_123")
sendly.workflows.stats(workflow["id"], {"from": "2026-08-01"})

# events.record is the v1 counterpart of the legacy events.track. Same effect,
# v1 dialect. It takes no idempotency_key: events are append-only and the API
# deliberately does not ledger them.
sendly.events.record({"name": "signup.completed", "contact_id": "c_123", "data": {"plan": "pro"}})
sendly.events.list({"event_name": "signup.completed", "limit": 20})
sendly.events.list_names()
sendly.events.stats({"from": "2026-08-01", "to": "2026-08-31"})

sendly.analytics.timeseries({"from": "2026-08-01", "to": "2026-08-31"})
sendly.analytics.campaigns()
sendly.analytics.top_campaigns({"limit": 5})

usage = sendly.usage.get()
print(usage["plan"], usage["monthly"])
```

## Error handling

Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
Expand Down Expand Up @@ -231,6 +350,40 @@ Invalid input raises `SendlyValidationError`. Migrated routes report it as HTTP
`err.body["error"]["details"]["errors"]`; legacy/malformed requests still use
`400`. Both surface as `SendlyValidationError`.

### v1 errors (RFC 9457)

The `/api/v1` surface reports failures as `application/problem+json` documents.
They raise the **same** exception classes, keyed off the same statuses, so
existing `except` blocks keep working. Three things move:

- `error_code` comes from the problem's `code` — a lowercase, machine-readable
value like `scope_missing`, `quota_exhausted`, or `idempotency_key_reused`.
- `err.request_id` carries the correlation id. Quote it in support requests.
- `err.field_errors` carries the per-field breakdown on a `validation_error`,
each entry `{pointer, code, message}` with an RFC 6901 JSON Pointer.

```python
from sendly import Sendly, SendlyValidationError, SendlyRateLimitError

sendly = Sendly()
try:
sendly.campaigns.create({"name": "Launch"})
except SendlyValidationError as err:
print(err.error_code, err.message, err.request_id)
for field in err.field_errors or []:
print(f" {field['pointer']}: {field['message']}")
except SendlyRateLimitError as err:
# Two different failures share this class — check the code before retrying.
if err.error_code == "quota_exhausted":
print("Plan limit reached; backing off will not help")
else:
print("Too fast — retry with backoff")
```

The full problem document stays on `err.body`, so `type`, `title` and `instance`
remain reachable. On the legacy surface `request_id` and `field_errors` are
`None`.

## Verifying webhooks

Every delivery is signed. Verify it against the **raw** request body — do not
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ build-backend = "hatchling.build"
# the PyPI trusted-publisher configuration). The import package is
# unchanged (`import sendly`) — see [tool.hatch.build.targets.wheel] below.
name = "sendly-python"
version = "0.1.0"
version = "0.2.0"
description = "Official Sendly Python SDK"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
18 changes: 18 additions & 0 deletions src/sendly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
>>> sendly.emails.send(
... {"from": "a@b.com", "to": "c@d.com", "subject": "hi", "body": "<p>hi</p>"}
... )

The same client also speaks the ``/api/v1`` surface — campaigns, segments,
workflows, analytics, usage, and the v1 event methods:

>>> for campaign in sendly.campaigns.iter_list({"limit": 100}):
... print(campaign["name"], campaign["status"])
"""

from __future__ import annotations
Expand All @@ -22,14 +28,20 @@
SendlyServerError,
SendlyValidationError,
)
from sendly.resources.analytics import AnalyticsResource
from sendly.resources.campaigns import CampaignsResource
from sendly.resources.contacts import ContactsResource
from sendly.resources.domains import DomainsResource
from sendly.resources.emails import EmailsResource
from sendly.resources.events import EventsResource
from sendly.resources.lists import ListsResource
from sendly.resources.segments import SegmentsResource
from sendly.resources.suppression import SuppressionResource
from sendly.resources.templates import TemplatesResource
from sendly.resources.usage import UsageResource
from sendly.resources.verify import VerifyResource
from sendly.resources.webhooks import WebhooksResource
from sendly.resources.workflows import WorkflowsResource
from sendly.webhook_utils import DEFAULT_TOLERANCE_MS, construct_event, verify_signature

__version__ = SDK_VERSION
Expand All @@ -38,10 +50,14 @@
"DEFAULT_BASE_URL",
"DEFAULT_TOLERANCE_MS",
"SDK_VERSION",
"AnalyticsResource",
"CampaignsResource",
"ContactsResource",
"DomainsResource",
"EmailsResource",
"EventsResource",
"ListsResource",
"SegmentsResource",
"Sendly",
"SendlyAuthenticationError",
"SendlyConflictError",
Expand All @@ -54,8 +70,10 @@
"SendlyValidationError",
"SuppressionResource",
"TemplatesResource",
"UsageResource",
"VerifyResource",
"WebhooksResource",
"WorkflowsResource",
"__version__",
"construct_event",
"verify_signature",
Expand Down
55 changes: 47 additions & 8 deletions src/sendly/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
* Error envelope ``{error: {code, message}}`` mapped to typed exceptions.
* Query params skip ``None``/empty-string; list values append repeated keys.
* 204 / No-Content -> ``None``; non-JSON success body -> raw text.

One client, two response dialects. The legacy ``/api/*`` resources wrap results
in ``{success, data}`` and report failures as ``{error: {code, message}}``. The
``/api/v1/*`` resources (``campaigns``, ``segments``, ``workflows``,
``analytics``, ``usage``, and the v1 methods on ``events``) return the resource
body directly — no envelope, so they never call :meth:`Sendly.unwrap` — and
report failures as RFC 9457 problem documents. Both dialects raise the same
:class:`~sendly.errors.SendlyError` subclasses.
"""

from __future__ import annotations
Expand All @@ -18,15 +26,27 @@

import httpx

from sendly.errors import SendlyConnectionError, SendlyError, error_from_response
from sendly.errors import (
SendlyConnectionError,
SendlyError,
error_from_problem,
error_from_response,
is_problem_document,
)
from sendly.resources.analytics import AnalyticsResource
from sendly.resources.campaigns import CampaignsResource
from sendly.resources.contacts import ContactsResource
from sendly.resources.domains import DomainsResource
from sendly.resources.emails import EmailsResource
from sendly.resources.events import EventsResource
from sendly.resources.lists import ListsResource
from sendly.resources.segments import SegmentsResource
from sendly.resources.suppression import SuppressionResource
from sendly.resources.templates import TemplatesResource
from sendly.resources.usage import UsageResource
from sendly.resources.verify import VerifyResource
from sendly.resources.webhooks import WebhooksResource
from sendly.resources.workflows import WorkflowsResource

if TYPE_CHECKING:
from collections.abc import Mapping
Expand All @@ -37,7 +57,7 @@
__all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]

#: Package version. Kept in sync with ``pyproject.toml``.
SDK_VERSION = "0.1.0"
SDK_VERSION = "0.2.0"

#: Default production API base. Override via ``base_url`` for staging/self-hosted.
DEFAULT_BASE_URL = "https://api.sendly.now"
Expand All @@ -56,9 +76,11 @@ def _stringify(value: Any) -> str:
class Sendly:
"""Sendly SDK entry point.

Construct once with an API key and reuse the resource accessors
(``emails``, ``contacts``, ``events``, ``domains``, ``templates``,
``verify``, ``webhooks``, ``suppression``) for all calls.
Construct once with an API key and reuse the resource accessors for all
calls: ``emails``, ``contacts``, ``events``, ``domains``, ``templates``,
``verify``, ``webhooks``, ``suppression`` and ``lists`` on the legacy
surface, plus ``campaigns``, ``segments``, ``workflows``, ``analytics`` and
``usage`` on ``/api/v1``.

Args:
api_key: Project API key (``sk_*`` for full access, ``pk_*`` for
Expand Down Expand Up @@ -112,6 +134,14 @@ def __init__(
self.verify = VerifyResource(self)
self.webhooks = WebhooksResource(self)
self.suppression = SuppressionResource(self)
self.lists = ListsResource(self)
# /api/v1 surface. Same client, same auth; bare resource bodies instead
# of the legacy {success, data} envelope, and RFC 9457 problem errors.
self.campaigns = CampaignsResource(self)
self.segments = SegmentsResource(self)
self.workflows = WorkflowsResource(self)
self.analytics = AnalyticsResource(self)
self.usage = UsageResource(self)

def request(
self,
Expand Down Expand Up @@ -180,7 +210,9 @@ def request(
return text

if not response.is_success:
self._raise_from_body(response.status_code, parsed)
self._raise_from_body(
response.status_code, parsed, response.headers.get("content-type")
)

return parsed

Expand Down Expand Up @@ -238,9 +270,16 @@ def _raise_for_error(self, response: httpx.Response) -> NoReturn:
body = json.loads(text)
except json.JSONDecodeError:
body = None
self._raise_from_body(response.status_code, body)
self._raise_from_body(response.status_code, body, response.headers.get("content-type"))

def _raise_from_body(
self, status_code: int, body: Any, content_type: str | None = None
) -> NoReturn:
# /api/v1 speaks RFC 9457; the legacy surface speaks {success, error}.
# Both land on the same exception classes, keyed off the status.
if is_problem_document(body, content_type):
raise error_from_problem(status_code, body)

def _raise_from_body(self, status_code: int, body: Any) -> NoReturn:
error = body.get("error") if isinstance(body, dict) else None
error = error if isinstance(error, dict) else {}
raw_message = error.get("message")
Expand Down
Loading
Loading