From 2e9b8e1c086e425576f59f59dd6b55ae2c0f80df Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:15:40 +0100 Subject: [PATCH 1/4] feat(v1): wrap the /api/v1 surface with RFC 9457 errors and cursor auto-pagination --- src/sendly/__init__.py | 18 + src/sendly/client.py | 54 +- src/sendly/errors.py | 142 +- src/sendly/resources/_pagination.py | 49 + src/sendly/resources/analytics.py | 49 + src/sendly/resources/campaigns.py | 135 + src/sendly/resources/events.py | 80 +- src/sendly/resources/lists.py | 59 + src/sendly/resources/segments.py | 106 + src/sendly/resources/usage.py | 27 + src/sendly/resources/workflows.py | 138 + src/sendly/types.py | 41 + tests/fixtures/openapi.json | 8270 ++++++++++++++++++++++----- tests/support.py | 39 + tests/test_analytics.py | 70 + tests/test_campaigns.py | 262 + tests/test_contract.py | 99 +- tests/test_errors_problem.py | 198 + tests/test_events.py | 133 +- tests/test_lists.py | 123 + tests/test_segments.py | 125 + tests/test_usage.py | 56 + tests/test_workflows.py | 159 + 23 files changed, 9079 insertions(+), 1353 deletions(-) create mode 100644 src/sendly/resources/_pagination.py create mode 100644 src/sendly/resources/analytics.py create mode 100644 src/sendly/resources/campaigns.py create mode 100644 src/sendly/resources/lists.py create mode 100644 src/sendly/resources/segments.py create mode 100644 src/sendly/resources/usage.py create mode 100644 src/sendly/resources/workflows.py create mode 100644 tests/test_analytics.py create mode 100644 tests/test_campaigns.py create mode 100644 tests/test_errors_problem.py create mode 100644 tests/test_lists.py create mode 100644 tests/test_segments.py create mode 100644 tests/test_usage.py create mode 100644 tests/test_workflows.py diff --git a/src/sendly/__init__.py b/src/sendly/__init__.py index 54407fe..fad42d5 100644 --- a/src/sendly/__init__.py +++ b/src/sendly/__init__.py @@ -6,6 +6,12 @@ >>> sendly.emails.send( ... {"from": "a@b.com", "to": "c@d.com", "subject": "hi", "body": "

hi

"} ... ) + +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 @@ -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 @@ -38,10 +50,14 @@ "DEFAULT_BASE_URL", "DEFAULT_TOLERANCE_MS", "SDK_VERSION", + "AnalyticsResource", + "CampaignsResource", "ContactsResource", "DomainsResource", "EmailsResource", "EventsResource", + "ListsResource", + "SegmentsResource", "Sendly", "SendlyAuthenticationError", "SendlyConflictError", @@ -54,8 +70,10 @@ "SendlyValidationError", "SuppressionResource", "TemplatesResource", + "UsageResource", "VerifyResource", "WebhooksResource", + "WorkflowsResource", "__version__", "construct_event", "verify_signature", diff --git a/src/sendly/client.py b/src/sendly/client.py index 552bd65..88ea8c4 100644 --- a/src/sendly/client.py +++ b/src/sendly/client.py @@ -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 @@ -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 @@ -56,9 +76,12 @@ 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 @@ -112,6 +135,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, @@ -180,7 +211,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 @@ -238,9 +271,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") diff --git a/src/sendly/errors.py b/src/sendly/errors.py index 10fbbe6..92193f6 100644 --- a/src/sendly/errors.py +++ b/src/sendly/errors.py @@ -3,30 +3,63 @@ Mirrors the TypeScript SDK's ``errors.ts``: a single :class:`SendlyError` base with one subclass per meaningful HTTP status so callers can ``except`` a narrow type without inspecting the response body. + +The API speaks two error dialects and both land on the same exception classes: + +* legacy ``/api/*`` — ``{success: false, error: {code, message}}``; +* ``/api/v1/*`` — an RFC 9457 problem document served as + ``application/problem+json``. Its ``code`` becomes :attr:`SendlyError.error_code` + and its ``detail`` (falling back to ``title``) becomes the message, so + ``except SendlyValidationError`` behaves identically across both surfaces. + Two problem-only fields are surfaced additively: :attr:`SendlyError.request_id` + and :attr:`SendlyError.field_errors`. """ from __future__ import annotations from typing import Any +#: Media type of an RFC 9457 problem document. +PROBLEM_CONTENT_TYPE = "application/problem+json" + class SendlyError(Exception): """Base error for any non-2xx HTTP response or transport failure. Attributes: status_code: HTTP status (``0`` for client-side/transport failures). - error_code: Machine-readable code from the API error envelope, or a - synthesized ``http_`` / ``invalid_response`` / ``connection_error``. + error_code: Machine-readable code from the API error envelope (legacy + ``error.code`` or v1 problem ``code``), or a synthesized + ``http_`` / ``invalid_response`` / ``connection_error``. message: Human-readable message. - body: The parsed (or raw) response body, when available. + body: The parsed (or raw) response body, when available. For a v1 + failure this is the whole problem document, so ``type``, ``title``, + ``instance`` and any other member stays reachable. + request_id: Correlation id from a v1 problem document (``request_id``); + ``None`` on the legacy surface. Quote it in support requests. + field_errors: Field-level failures from a v1 ``validation_error`` + problem (``errors``), each ``{pointer, code, message}``; ``None`` + when the response carried none. The legacy surface puts its own + breakdown at ``body["error"]["details"]["errors"]`` instead. """ - def __init__(self, status_code: int, error_code: str, message: str, body: Any = None) -> None: + def __init__( + self, + status_code: int, + error_code: str, + message: str, + body: Any = None, + *, + request_id: str | None = None, + field_errors: list[dict[str, Any]] | None = None, + ) -> None: super().__init__(message) self.status_code = status_code self.error_code = error_code self.message = message self.body = body + self.request_id = request_id + self.field_errors = field_errors class SendlyValidationError(SendlyError): @@ -69,24 +102,95 @@ def __init__(self, message: str, body: Any = None) -> None: super().__init__(0, "connection_error", message, body) -def error_from_response( - status_code: int, error_code: str, message: str, body: Any = None -) -> SendlyError: - """Map an HTTP status + error envelope to the appropriate error subclass.""" - if status_code == 400: - return SendlyValidationError(status_code, error_code, message, body) +def _error_class(status_code: int) -> type[SendlyError]: + """The exception class a status maps to. Shared by both error dialects.""" + if status_code in (400, 422): + return SendlyValidationError if status_code == 401: - return SendlyAuthenticationError(status_code, error_code, message, body) + return SendlyAuthenticationError if status_code == 403: - return SendlyPermissionError(status_code, error_code, message, body) + return SendlyPermissionError if status_code == 404: - return SendlyNotFoundError(status_code, error_code, message, body) - if status_code == 422: - return SendlyValidationError(status_code, error_code, message, body) + return SendlyNotFoundError if status_code == 409: - return SendlyConflictError(status_code, error_code, message, body) + return SendlyConflictError if status_code == 429: - return SendlyRateLimitError(status_code, error_code, message, body) + return SendlyRateLimitError if status_code >= 500: - return SendlyServerError(status_code, error_code, message, body) - return SendlyError(status_code, error_code, message, body) + return SendlyServerError + return SendlyError + + +def error_from_response( + status_code: int, + error_code: str, + message: str, + body: Any = None, + *, + request_id: str | None = None, + field_errors: list[dict[str, Any]] | None = None, +) -> SendlyError: + """Map an HTTP status + error envelope to the appropriate error subclass.""" + return _error_class(status_code)( + status_code, + error_code, + message, + body, + request_id=request_id, + field_errors=field_errors, + ) + + +def is_problem_document(body: Any, content_type: str | None = None) -> bool: + """Is this response body an RFC 9457 problem document? + + Trusts the ``application/problem+json`` content type when present, and + otherwise falls back to the document shape (``type`` + ``title`` + ``code``), + so a proxy that rewrites the media type cannot downgrade a v1 error into the + generic ``http_`` path. The legacy ``{success, error}`` envelope + carries none of those members, so it can never match. + """ + if not isinstance(body, dict): + return False + if content_type and PROBLEM_CONTENT_TYPE in content_type.lower(): + return True + return all(isinstance(body.get(key), str) for key in ("type", "title", "code")) + + +def error_from_problem(status_code: int, problem: dict[str, Any]) -> SendlyError: + """Map an RFC 9457 problem document to the exception class for its status. + + ``code`` supplies the machine-readable :attr:`SendlyError.error_code` and + ``detail`` the message, falling back to ``title`` — a problem document always + carries a title but only sometimes an occurrence-specific detail. + """ + raw_code = problem.get("code") + code = raw_code if isinstance(raw_code, str) and raw_code else f"http_{status_code}" + + message = "" + for key in ("detail", "title"): + value = problem.get(key) + if isinstance(value, str) and value: + message = value + break + if not message: + message = f"Sendly request failed with status {status_code}" + + raw_request_id = problem.get("request_id") + request_id = raw_request_id if isinstance(raw_request_id, str) else None + + raw_errors = problem.get("errors") + field_errors = ( + [item for item in raw_errors if isinstance(item, dict)] + if isinstance(raw_errors, list) + else None + ) + + return error_from_response( + status_code, + code, + message, + problem, + request_id=request_id, + field_errors=field_errors, + ) diff --git a/src/sendly/resources/_pagination.py b/src/sendly/resources/_pagination.py new file mode 100644 index 0000000..70af8b1 --- /dev/null +++ b/src/sendly/resources/_pagination.py @@ -0,0 +1,49 @@ +"""Auto-pagination over the ``/api/v1`` cursor envelope. + +Every v1 list endpoint answers with ``{data, has_more, next_cursor}``: an opaque +forward-only cursor and no total (deliberately — counting a project's rows is a +scan the API refuses to pay for on every page). :func:`iterate_cursor` walks that +shape so callers can treat a multi-page listing as one stream of items. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + from sendly.types import JSONDict, Query + + +def iterate_cursor( + fetch: Callable[[dict[str, Any]], Any], + query: Query | None = None, +) -> Iterator[JSONDict]: + """Yield every item across the pages ``fetch`` returns. + + ``fetch`` receives the query for one page — the caller's own filters, plus + the ``after`` cursor from page two onward — and returns the raw cursor + envelope. Iteration stops when ``has_more`` is false or ``next_cursor`` is + ``None``, and also when a page carries no ``data`` list, so a malformed + response ends the walk instead of looping forever. + + The caller's filters are held fixed for the whole walk on purpose: changing + them mid-pagination invalidates the cursor and the API answers ``422 + validation_error`` telling you to restart from the first page. + """ + params = dict(query or {}) + while True: + page = fetch(params) + if not isinstance(page, dict): + return + items = page.get("data") + if not isinstance(items, list): + return + yield from items + if not page.get("has_more"): + return + cursor = page.get("next_cursor") + if not cursor: + return + params = {**params, "after": cursor} diff --git a/src/sendly/resources/analytics.py b/src/sendly/resources/analytics.py new file mode 100644 index 0000000..a7d5041 --- /dev/null +++ b/src/sendly/resources/analytics.py @@ -0,0 +1,49 @@ +"""Analytics resource (``/api/v1``).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sendly.client import Sendly + from sendly.types import ( + AnalyticsTimeseries, + CampaignAnalytics, + Query, + TopCampaignList, + ) + + +class AnalyticsResource: + """Aggregate sending and engagement metrics. + + Every method takes an optional ``from`` / ``to`` window (ISO 8601) and echoes + the resolved window back as ``window``, so a caller can tell what the API + actually measured when it defaulted the range. None of these are + cursor-paginated — they answer a bounded aggregate, not a listing — so there + are no iterators here. + """ + + def __init__(self, client: Sendly) -> None: + self._client = client + + def timeseries(self, query: Query | None = None) -> AnalyticsTimeseries: + """Per-day sending and engagement counts over the window.""" + response: AnalyticsTimeseries = self._client.request( + method="GET", path="/api/v1/analytics/timeseries", query=query + ) + return response + + def campaigns(self, query: Query | None = None) -> CampaignAnalytics: + """Campaign totals for the window: counts plus average open/click rates.""" + response: CampaignAnalytics = self._client.request( + method="GET", path="/api/v1/analytics/campaigns", query=query + ) + return response + + def top_campaigns(self, query: Query | None = None) -> TopCampaignList: + """Best-performing campaigns in the window. Accepts ``limit``.""" + response: TopCampaignList = self._client.request( + method="GET", path="/api/v1/analytics/top-campaigns", query=query + ) + return response diff --git a/src/sendly/resources/campaigns.py b/src/sendly/resources/campaigns.py new file mode 100644 index 0000000..accde8d --- /dev/null +++ b/src/sendly/resources/campaigns.py @@ -0,0 +1,135 @@ +"""Campaigns resource (``/api/v1``).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sendly.resources._helpers import encode_path_segment, idempotency_headers +from sendly.resources._pagination import iterate_cursor + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sendly.client import Sendly + from sendly.types import ( + Body, + CampaignDeleted, + CampaignList, + CampaignRecord, + CampaignStats, + JSONDict, + Query, + ) + + +class CampaignsResource: + """Create, schedule, and run bulk email campaigns. + + A campaign moves through ``DRAFT`` -> ``SENDING`` -> ``SENT``; :meth:`send` + starts it (optionally at a future time), and :meth:`pause` / :meth:`resume` / + :meth:`cancel` steer it while in flight. Responses are bare v1 resource + bodies — there is no ``{success, data}`` envelope to unwrap. + """ + + def __init__(self, client: Sendly) -> None: + self._client = client + + def list(self, query: Query | None = None) -> CampaignList: + """List campaigns, newest first. + + Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and + answers ``{data, has_more, next_cursor}``. Keep the filters identical for + every page of one walk — changing them invalidates the cursor and the API + answers 422 ``validation_error`` telling you to restart from the first + page. :meth:`iter_list` does that bookkeeping for you. + """ + response: CampaignList = self._client.request( + method="GET", path="/api/v1/campaigns", query=query + ) + return response + + def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]: + """Iterate every campaign across pages, following the cursor for you.""" + return iterate_cursor(self.list, query) + + def create(self, body: Body, *, idempotency_key: str | None = None) -> CampaignRecord: + """Create a campaign in ``DRAFT``. + + Requires ``name``, ``subject``, ``body``, ``from`` and ``audience_type``. + Pass ``idempotency_key`` (1-255 chars) to make a replayed create return + the original campaign instead of a second one. + """ + response: CampaignRecord = self._client.request( + method="POST", + path="/api/v1/campaigns", + body=body, + headers=idempotency_headers(idempotency_key), + ) + return response + + def get(self, id: str) -> CampaignRecord: + """Fetch a single campaign, including its delivery ``stats``.""" + response: CampaignRecord = self._client.request( + method="GET", path=f"/api/v1/campaigns/{encode_path_segment(id)}" + ) + return response + + def update(self, id: str, body: Body) -> CampaignRecord: + """Patch a draft campaign's content or audience.""" + response: CampaignRecord = self._client.request( + method="PATCH", path=f"/api/v1/campaigns/{encode_path_segment(id)}", body=body + ) + return response + + def delete(self, id: str) -> CampaignDeleted: + """Delete a campaign. Returns the ``{id, deleted}`` confirmation body.""" + response: CampaignDeleted = self._client.request( + method="DELETE", path=f"/api/v1/campaigns/{encode_path_segment(id)}" + ) + return response + + def send( + self, id: str, body: Body | None = None, *, idempotency_key: str | None = None + ) -> CampaignRecord: + """Send a campaign now, or schedule it. + + Pass ``{"scheduled_for": ""}`` as ``body`` to queue it for a + future time instead of sending immediately. ``idempotency_key`` is the + guard that matters most on this call: a replayed send must not mail the + audience twice. + """ + response: CampaignRecord = self._client.request( + method="POST", + path=f"/api/v1/campaigns/{encode_path_segment(id)}/send", + body=body, + headers=idempotency_headers(idempotency_key), + ) + return response + + def cancel(self, id: str) -> CampaignRecord: + """Cancel a scheduled or in-flight campaign. Already-sent mail stays sent.""" + response: CampaignRecord = self._client.request( + method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/cancel" + ) + return response + + def pause(self, id: str) -> CampaignRecord: + """Pause an in-flight campaign, holding the remaining recipients.""" + response: CampaignRecord = self._client.request( + method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/pause" + ) + return response + + def resume(self, id: str) -> CampaignRecord: + """Resume a paused campaign from where it stopped.""" + response: CampaignRecord = self._client.request( + method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/resume" + ) + return response + + def stats(self, id: str) -> CampaignStats: + """Delivery and engagement counters plus derived rates for one campaign.""" + response: CampaignStats = self._client.request( + method="GET", path=f"/api/v1/campaigns/{encode_path_segment(id)}/stats" + ) + return response diff --git a/src/sendly/resources/events.py b/src/sendly/resources/events.py index 7f6878f..86f6f57 100644 --- a/src/sendly/resources/events.py +++ b/src/sendly/resources/events.py @@ -1,22 +1,43 @@ -"""Events resource.""" +"""Events resource (legacy ``/api/track`` + the ``/api/v1/events`` surface).""" from __future__ import annotations from typing import TYPE_CHECKING +from sendly.resources._pagination import iterate_cursor + if TYPE_CHECKING: + from collections.abc import Iterator + from sendly.client import Sendly - from sendly.types import Body, TrackEventData + from sendly.types import ( + Body, + EventList, + EventNameList, + EventRecord, + EventStats, + JSONDict, + Query, + TrackEventData, + ) class EventsResource: - """Record custom events for contacts.""" + """Record custom events for contacts, and query the ones already recorded. + + Two write methods, one per API surface. :meth:`track` is the original + ``POST /api/track`` call and is unchanged; :meth:`record` is its ``/api/v1`` + counterpart. They do the same thing — the difference is the dialect: v1 + returns the event body directly and reports failures as RFC 9457 problem + documents. New code should prefer :meth:`record`, alongside the v1 read + methods below. + """ def __init__(self, client: Sendly) -> None: self._client = client def track(self, body: Body) -> TrackEventData: - """Record a custom event for a contact. + """Record a custom event for a contact (legacy ``/api/track``). Both full (``sk_*``) and sending-only (``pk_*``) keys are accepted. Reserved system event names (e.g. ``email.sent``) are rejected by the API. @@ -24,3 +45,54 @@ def track(self, body: Body) -> TrackEventData: envelope = self._client.request(method="POST", path="/api/track", body=body) data: TrackEventData = self._client.unwrap(envelope) return data + + def record(self, body: Body) -> EventRecord: + """Record a custom event for a contact (``/api/v1``). + + Requires ``name``; optionally takes ``contact_id`` and a ``data`` object. + The v1 counterpart of :meth:`track`, returning the created event body + rather than a ``{success, data}`` envelope. + + Takes no ``Idempotency-Key``: events are the highest-volume write on the + surface and append-only by nature, so the API deliberately does not + ledger them. If a duplicate would matter to you, dedupe on your side. + """ + response: EventRecord = self._client.request( + method="POST", path="/api/v1/events", body=body + ) + return response + + def list(self, query: Query | None = None) -> EventList: + """List recorded events, newest first. + + Accepts ``limit`` (1-100, default 20), ``after`` (opaque cursor), and + ``event_name`` to filter to one event. Answers + ``{data, has_more, next_cursor}`` — no total, deliberately. Keep the + filters identical across one walk: changing them invalidates the cursor + and the API answers 422 ``validation_error`` telling you to restart from + the first page. + """ + response: EventList = self._client.request(method="GET", path="/api/v1/events", query=query) + return response + + def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]: + """Iterate every matching event across pages, following the cursor for you.""" + return iterate_cursor(self.list, query) + + def list_names(self, query: Query | None = None) -> EventNameList: + """The distinct event names recorded on the project. + + Useful for building a workflow trigger: a workflow's ``event_name`` has + to match a name events are actually recorded under. + """ + response: EventNameList = self._client.request( + method="GET", path="/api/v1/events/names", query=query + ) + return response + + def stats(self, query: Query | None = None) -> EventStats: + """Per-event counts over an optional ``from`` / ``to`` window.""" + response: EventStats = self._client.request( + method="GET", path="/api/v1/events/stats", query=query + ) + return response diff --git a/src/sendly/resources/lists.py b/src/sendly/resources/lists.py new file mode 100644 index 0000000..4a1b621 --- /dev/null +++ b/src/sendly/resources/lists.py @@ -0,0 +1,59 @@ +"""Lists resource (subscribe / unsubscribe).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sendly.resources._helpers import encode_path_segment + +if TYPE_CHECKING: + from sendly.client import Sendly + from sendly.types import Body, ListSubscribeData, ListUnsubscribeData + + +class ListsResource: + """Manage a contact's membership on a subscriber list. + + Both calls accept sending-only (``pk_*``) keys so they can back a public + subscribe or preference form directly. + """ + + def __init__(self, client: Sendly) -> None: + self._client = client + + def subscribe(self, id: str, body: Body) -> ListSubscribeData: + """Subscribe an address to a list, creating the contact if needed. + + Requires ``email``. Two behaviours worth knowing before you wire this to + a form: + + * When the list has double opt-in, the membership is created ``PENDING`` + and the response carries a ``confirmToken``. Sendly does **not** send + the confirmation email — deliver ``/api/lists/confirm?token=`` + to the contact yourself. + * Re-subscribing an address that previously opted out fails with + ``409 RESUBSCRIBE_CONFIRMATION_REQUIRED`` unless the body sets + ``allowResubscribe: true``. Read ``previousStatus`` rather than + ``created`` to describe the transition back to the user. + """ + envelope = self._client.request( + method="POST", + path=f"/api/lists/{encode_path_segment(id)}/subscribe", + body=body, + ) + data: ListSubscribeData = self._client.unwrap(envelope) + return data + + def unsubscribe(self, id: str, body: Body) -> ListUnsubscribeData: + """Mark an address's membership on this list ``UNSUBSCRIBED``. + + Requires ``email``. Idempotent — unsubscribing an address that is not a + member succeeds. + """ + envelope = self._client.request( + method="POST", + path=f"/api/lists/{encode_path_segment(id)}/unsubscribe", + body=body, + ) + data: ListUnsubscribeData = self._client.unwrap(envelope) + return data diff --git a/src/sendly/resources/segments.py b/src/sendly/resources/segments.py new file mode 100644 index 0000000..248b680 --- /dev/null +++ b/src/sendly/resources/segments.py @@ -0,0 +1,106 @@ +"""Segments resource (``/api/v1``).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sendly.resources._helpers import encode_path_segment +from sendly.resources._pagination import iterate_cursor + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sendly.client import Sendly + from sendly.types import ( + Body, + ContactList, + JSONDict, + Query, + SegmentDeleted, + SegmentList, + SegmentRecord, + ) + + +class SegmentsResource: + """Group contacts into static lists or dynamic, condition-driven audiences. + + A ``DYNAMIC`` segment's ``condition`` is evaluated by the API — its + ``member_count`` is computed at creation and kept current — so an invalid + condition fails the create with a 422 rather than silently matching nothing. + """ + + def __init__(self, client: Sendly) -> None: + self._client = client + + def list(self, query: Query | None = None) -> SegmentList: + """List segments. + + Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and + answers ``{data, has_more, next_cursor}``. Hold the filters steady across + one walk — changing them mid-pagination invalidates the cursor and the + API answers 422 ``validation_error`` telling you to restart from the + first page. + """ + response: SegmentList = self._client.request( + method="GET", path="/api/v1/segments", query=query + ) + return response + + def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]: + """Iterate every segment across pages, following the cursor for you.""" + return iterate_cursor(self.list, query) + + def create(self, body: Body) -> SegmentRecord: + """Create a segment. Requires ``name``. + + Takes no ``Idempotency-Key``: creating a segment neither sends anything + nor consumes quota, so a duplicate costs one row that a + :meth:`delete` undoes. + """ + response: SegmentRecord = self._client.request( + method="POST", path="/api/v1/segments", body=body + ) + return response + + def get(self, id: str) -> SegmentRecord: + """Fetch a single segment, including its current ``member_count``.""" + response: SegmentRecord = self._client.request( + method="GET", path=f"/api/v1/segments/{encode_path_segment(id)}" + ) + return response + + def update(self, id: str, body: Body) -> SegmentRecord: + """Patch a segment's name, description, condition, or membership tracking.""" + response: SegmentRecord = self._client.request( + method="PATCH", path=f"/api/v1/segments/{encode_path_segment(id)}", body=body + ) + return response + + def delete(self, id: str) -> SegmentDeleted: + """Delete a segment. Returns the ``{id, deleted}`` confirmation body. + + Removes the grouping, not the contacts in it. + """ + response: SegmentDeleted = self._client.request( + method="DELETE", path=f"/api/v1/segments/{encode_path_segment(id)}" + ) + return response + + def list_contacts(self, id: str, query: Query | None = None) -> ContactList: + """List the contacts currently in a segment. + + Cursor-paginated like :meth:`list` (``limit`` / ``after``). For a dynamic + segment this is evaluated against the live condition, so membership can + differ between two walks. + """ + response: ContactList = self._client.request( + method="GET", + path=f"/api/v1/segments/{encode_path_segment(id)}/contacts", + query=query, + ) + return response + + def iter_list_contacts(self, id: str, query: Query | None = None) -> Iterator[JSONDict]: + """Iterate every contact in a segment across pages.""" + return iterate_cursor(lambda params: self.list_contacts(id, params), query) diff --git a/src/sendly/resources/usage.py b/src/sendly/resources/usage.py new file mode 100644 index 0000000..def4f83 --- /dev/null +++ b/src/sendly/resources/usage.py @@ -0,0 +1,27 @@ +"""Usage resource (``/api/v1``).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sendly.client import Sendly + from sendly.types import UsageSummary + + +class UsageResource: + """The caller's plan and its current quota consumption.""" + + def __init__(self, client: Sendly) -> None: + self._client = client + + def get(self) -> UsageSummary: + """Current ``plan`` plus ``monthly`` and ``daily`` usage against its limits. + + Read this before a large send to see the headroom the API would enforce: + exceeding a quota answers 429 ``quota_exhausted``, which is a different + failure from 429 ``rate_limited`` (too fast, retry) and is not fixed by + backing off. + """ + response: UsageSummary = self._client.request(method="GET", path="/api/v1/usage") + return response diff --git a/src/sendly/resources/workflows.py b/src/sendly/resources/workflows.py new file mode 100644 index 0000000..2e0cc93 --- /dev/null +++ b/src/sendly/resources/workflows.py @@ -0,0 +1,138 @@ +"""Workflows resource (``/api/v1``).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sendly.resources._helpers import encode_path_segment +from sendly.resources._pagination import iterate_cursor + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sendly.client import Sendly + from sendly.types import ( + Body, + JSONDict, + Query, + WorkflowDeleted, + WorkflowExecutionList, + WorkflowExecutionRecord, + WorkflowList, + WorkflowRecord, + WorkflowStats, + ) + + +class WorkflowsResource: + """Event-triggered automations and their per-contact executions. + + A workflow fires when its ``event_name`` arrives for a contact (see + :meth:`~sendly.resources.events.EventsResource.record`); ``allow_reentry`` + decides whether a contact already running the workflow can start it again. + """ + + def __init__(self, client: Sendly) -> None: + self._client = client + + def list(self, query: Query | None = None) -> WorkflowList: + """List workflows. + + Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and + answers ``{data, has_more, next_cursor}``. Keep the filters identical for + every page of one walk — changing them invalidates the cursor and the API + answers 422 ``validation_error`` telling you to restart from the first + page. + """ + response: WorkflowList = self._client.request( + method="GET", path="/api/v1/workflows", query=query + ) + return response + + def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]: + """Iterate every workflow across pages, following the cursor for you.""" + return iterate_cursor(self.list, query) + + def create(self, body: Body) -> WorkflowRecord: + """Create a workflow. Requires ``name`` and ``event_name``.""" + response: WorkflowRecord = self._client.request( + method="POST", path="/api/v1/workflows", body=body + ) + return response + + def get(self, id: str) -> WorkflowRecord: + """Fetch a single workflow.""" + response: WorkflowRecord = self._client.request( + method="GET", path=f"/api/v1/workflows/{encode_path_segment(id)}" + ) + return response + + def update(self, id: str, body: Body) -> WorkflowRecord: + """Patch a workflow — including ``enabled``, which is how you pause one.""" + response: WorkflowRecord = self._client.request( + method="PATCH", path=f"/api/v1/workflows/{encode_path_segment(id)}", body=body + ) + return response + + def delete(self, id: str) -> WorkflowDeleted: + """Delete a workflow. Returns the ``{id, deleted}`` confirmation body.""" + response: WorkflowDeleted = self._client.request( + method="DELETE", path=f"/api/v1/workflows/{encode_path_segment(id)}" + ) + return response + + def list_executions(self, id: str, query: Query | None = None) -> WorkflowExecutionList: + """List one workflow's executions. + + Cursor-paginated (``limit`` / ``after``) and filterable by ``status``. + As with every v1 listing, a filter you change mid-walk invalidates the + cursor — restart from the first page instead. + """ + response: WorkflowExecutionList = self._client.request( + method="GET", + path=f"/api/v1/workflows/{encode_path_segment(id)}/executions", + query=query, + ) + return response + + def iter_list_executions(self, id: str, query: Query | None = None) -> Iterator[JSONDict]: + """Iterate every execution of a workflow across pages.""" + return iterate_cursor(lambda params: self.list_executions(id, params), query) + + def start_execution(self, id: str, body: Body | None = None) -> WorkflowExecutionRecord: + """Start the workflow for one contact, bypassing its event trigger. + + ``body`` requires ``contact_id`` and may carry a ``context`` object the + workflow's steps can read. + """ + response: WorkflowExecutionRecord = self._client.request( + method="POST", + path=f"/api/v1/workflows/{encode_path_segment(id)}/executions", + body=body, + ) + return response + + def cancel_execution(self, execution_id: str) -> WorkflowExecutionRecord: + """Cancel one in-flight execution. + + Addressed by execution id alone — the route is + ``/api/v1/workflows/executions/{execution_id}/cancel``, not nested under + the workflow — so a caller holding an execution id needs nothing else. + """ + response: WorkflowExecutionRecord = self._client.request( + method="POST", + path=f"/api/v1/workflows/executions/{encode_path_segment(execution_id)}/cancel", + ) + return response + + def stats(self, id: str, query: Query | None = None) -> WorkflowStats: + """Execution totals, completion rate, and attributed email/conversion counts. + + Accepts ``from`` to bound the window. + """ + response: WorkflowStats = self._client.request( + method="GET", + path=f"/api/v1/workflows/{encode_path_segment(id)}/stats", + query=query, + ) + return response diff --git a/src/sendly/types.py b/src/sendly/types.py index 1c2f9de..bdb406f 100644 --- a/src/sendly/types.py +++ b/src/sendly/types.py @@ -73,6 +73,11 @@ SuppressionListResponse = JSONDict SuppressionCheckResponse = JSONDict +# ---------- Lists ---------- + +ListSubscribeData = JSONDict +ListUnsubscribeData = JSONDict + # ---------- Events ---------- TrackEventData = JSONDict @@ -82,3 +87,39 @@ VerifyEmailData = JSONDict VerifyEmailResponse = JSONDict + +# ---------- /api/v1 ---------- +# +# The v1 surface returns bare resource bodies (no {success, data} envelope) and +# a uniform list envelope: {data, has_more, next_cursor}. The ``*List`` aliases +# below name that envelope; the iterator methods yield the items inside ``data``. + +CursorList = JSONDict + +CampaignRecord = JSONDict +CampaignList = CursorList +CampaignStats = JSONDict +CampaignDeleted = JSONDict + +SegmentRecord = JSONDict +SegmentList = CursorList +SegmentDeleted = JSONDict +ContactList = CursorList + +WorkflowRecord = JSONDict +WorkflowList = CursorList +WorkflowDeleted = JSONDict +WorkflowStats = JSONDict +WorkflowExecutionRecord = JSONDict +WorkflowExecutionList = CursorList + +EventRecord = JSONDict +EventList = CursorList +EventNameList = JSONDict +EventStats = JSONDict + +AnalyticsTimeseries = JSONDict +CampaignAnalytics = JSONDict +TopCampaignList = JSONDict + +UsageSummary = JSONDict diff --git a/tests/fixtures/openapi.json b/tests/fixtures/openapi.json index 383f05a..02b5196 100644 --- a/tests/fixtures/openapi.json +++ b/tests/fixtures/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Sendly API", "version": "1.0.0", - "description": "Sendly's public REST API. Authenticate either with a project API key as `Authorization: Bearer ` (`sk_*` for full access, `pk_*` for sending-only) or with a BetterAuth session cookie. All endpoints return JSON envelopes of the form `{ success, data }` (success) or `{ error: { message, code } }` (failure).", + "description": "Sendly's public REST API. Authenticate either with a project API key as `Authorization: Bearer ` (`sk_*` for full access, `pk_*` for sending-only) or with a BetterAuth session cookie. Legacy `/api/*` endpoints return JSON envelopes of the form `{ success, data }` (success) or `{ error: { message, code } }` (failure). `/api/v1/*` endpoints return bare resource bodies on success and RFC 9457 `application/problem+json` documents on failure.", "contact": { "name": "Sendly Support", "url": "https://sendly.now" @@ -23,10 +23,22 @@ "name": "Emails", "description": "Send transactional email and inspect deliveries." }, + { + "name": "Campaigns", + "description": "Bulk sends to a list, segment, or filtered audience. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors)." + }, + { + "name": "Segments", + "description": "Saved audiences — a `DYNAMIC` filter re-evaluated on read, or a `STATIC` membership list. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors)." + }, { "name": "Contacts", "description": "Manage subscribers and per-contact custom data." }, + { + "name": "Lists", + "description": "Self-serve list membership. These two endpoints accept sending-only keys so they can back a public subscribe/unsubscribe form; list management itself is dashboard-only." + }, { "name": "Domains", "description": "Register sending domains and manage SES verification." @@ -43,10 +55,22 @@ "name": "Suppression", "description": "Project-scoped suppression list. Hard bounces and complaints land here automatically." }, + { + "name": "Workflows", + "description": "Event-triggered automations and the contact runs through them. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors)." + }, { "name": "Events", "description": "Track custom contact events from your application." }, + { + "name": "Analytics", + "description": "Aggregate sending and engagement metrics. Every read is bounded to a window of at most 90 days and cached for 15 minutes." + }, + { + "name": "Usage", + "description": "Current email usage against the monthly and daily limits the platform enforces." + }, { "name": "Verify", "description": "Open email-validation endpoint (no auth required). Used by the marketing-site verifier." @@ -58,7 +82,7 @@ "type": "http", "scheme": "bearer", "bearerFormat": "API Key", - "description": "API key authentication. Use a `sk_*` (FULL) or `pk_*` (SENDING_ONLY) key as the bearer token. Public keys (`pk_*`) are restricted to `POST /api/track` and email-send endpoints." + "description": "API key authentication. Use a `sk_*` (FULL) or `pk_*` (SENDING_ONLY) key as the bearer token. Public keys (`pk_*`) are restricted to the email-send endpoints and the self-serve list subscribe/unsubscribe pair; every other endpoint — event tracking included — answers 403 for them. In scope terms (used by `/api/v1/*` operations): FULL keys hold every scope; SENDING_ONLY keys hold only `emails:send`." }, "SessionAuth": { "type": "apiKey", @@ -110,6 +134,71 @@ ], "description": "Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`." }, + "Problem": { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "uri", + "description": "Dereferenceable URI identifying the error class, anchored on the docs errors page." + }, + "title": { + "type": "string", + "description": "Short, stable summary — the same for every occurrence of a `type`." + }, + "status": { + "type": "integer", + "description": "HTTP status code, repeated in the body." + }, + "detail": { + "type": "string", + "description": "Explanation specific to this occurrence." + }, + "instance": { + "type": "string", + "description": "Request path the failure occurred on." + }, + "code": { + "type": "string", + "description": "Machine-readable lowercase error code, e.g. `scope_missing`." + }, + "request_id": { + "type": "string", + "description": "Correlation id — quote it in support requests." + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pointer": { + "type": "string", + "description": "RFC 6901 JSON Pointer to the offending field." + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "pointer", + "code", + "message" + ] + }, + "description": "Field-level failures. Present on 422 `validation_error` responses." + } + }, + "required": [ + "type", + "title", + "status", + "code" + ], + "description": "RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface." + }, "SuccessEmpty": { "type": "object", "properties": { @@ -407,6 +496,28 @@ ] } }, + "mailFromDomain": { + "type": [ + "string", + "null" + ], + "description": "Custom MAIL FROM subdomain SES has on record (normally `sendly.`)." + }, + "mailFromStatus": { + "type": [ + "string", + "null" + ], + "enum": [ + "Pending", + "Success", + "Failed", + "TemporaryFailure", + "NotConfigured", + null + ], + "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it." + }, "createdAt": { "type": "string", "format": "date-time", @@ -483,6 +594,27 @@ "value" ] } + }, + "mailFromDomain": { + "type": [ + "string", + "null" + ] + }, + "mailFromStatus": { + "type": [ + "string", + "null" + ], + "enum": [ + "Pending", + "Success", + "Failed", + "TemporaryFailure", + "NotConfigured", + null + ], + "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it." } }, "required": [ @@ -559,16 +691,45 @@ ], "description": "A sent (or queued) transactional email." }, - "SendEmailData": { + "SendEmailRecipientResult": { "type": "object", "properties": { "contact": { - "type": "string", - "format": "uuid" + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "id", + "email" + ] }, "email": { "type": "string", "format": "uuid" + } + }, + "required": [ + "contact", + "email" + ], + "description": "Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient." + }, + "SendEmailData": { + "type": "object", + "properties": { + "emails": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SendEmailRecipientResult" + } }, "timestamp": { "type": "string", @@ -576,8 +737,11 @@ "description": "ISO 8601 datetime string" } }, - "additionalProperties": {}, - "description": "Per-send result returned by `executeSendEmail`." + "required": [ + "emails", + "timestamp" + ], + "description": "Result of a send (`executeSendEmail`): one `emails` entry per recipient — a single request with an array `to` fans out to several — plus the send `timestamp`." }, "SendEmailResponse": { "type": "object", @@ -589,24 +753,14 @@ ] }, "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/SendEmailData" - }, - { - "type": "array", - "items": { - "$ref": "#/components/schemas/SendEmailData" - } - } - ] + "$ref": "#/components/schemas/SendEmailData" } }, "required": [ "success", "data" ], - "description": "Successful send response. `data` is a single object for single sends, an array of `{index,status,data?,error?}` for batch." + "description": "Successful response for `POST /api/emails`. `data.emails[i].email` is the queued email id for recipient `i`; poll `GET /api/emails/{id}` for its delivery status." }, "BatchEntryResult": { "type": "object", @@ -1466,40 +1620,223 @@ ], "description": "Single email with its events." }, - "AddDomainBody": { + "ListSubscribeResponse": { "type": "object", "properties": { - "projectId": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "membershipId": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "PENDING", + "CONFIRMED", + "UNSUBSCRIBED" + ] + }, + "created": { + "type": "boolean", + "description": "True when the membership row did not exist before this call." + }, + "previousStatus": { + "type": [ + "string", + "null" + ], + "enum": [ + "PENDING", + "CONFIRMED", + "UNSUBSCRIBED", + null + ], + "description": "Status the membership held before this call; null when it did not exist. Use this rather than `created` to describe the transition to the user." + }, + "confirmToken": { + "type": "string", + "description": "Present only when the list has doubleOptIn enabled. Sendly does not send the confirmation email — deliver /api/lists/confirm?token= to the contact. Valid for 24 hours." + } + }, + "required": [ + "membershipId", + "status", + "created", + "previousStatus" + ] + } + }, + "required": [ + "success", + "data" + ], + "description": "Result of a list-subscribe call." + }, + "ListUnsubscribeResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ] + } + }, + "required": [ + "success", + "data" + ], + "description": "Echoes the address that was unsubscribed." + }, + "CampaignV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of campaigns." + }, + "CampaignV1": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, - "domain": { + "name": { + "type": "string" + }, + "status": { "type": "string", - "minLength": 3, - "maxLength": 253 + "enum": [ + "DRAFT", + "SCHEDULED", + "SENDING", + "PAUSED", + "SENT", + "CANCELLED" + ] }, - "region": { + "subject": { + "type": "string" + }, + "audience_type": { "type": "string", "enum": [ - "us-east-1", - "us-west-2", - "eu-west-1" + "ALL", + "FILTERED", + "SEGMENT" + ] + }, + "scheduled_at": { + "type": [ + "string", + "null" ], - "description": "Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region." + "format": "date-time" + }, + "sent_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "stats": { + "type": "object", + "properties": { + "total_recipients": { + "type": "integer" + }, + "sent": { + "type": "integer" + }, + "delivered": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + }, + "bounced": { + "type": "integer" + } + }, + "required": [ + "total_recipients", + "sent", + "delivered", + "opened", + "clicked", + "bounced" + ] } }, "required": [ - "domain" + "id", + "name", + "status", + "subject", + "audience_type", + "scheduled_at", + "sent_at", + "created_at", + "stats" ], - "description": "Body for POST /api/domains. `projectId` is optional for API-key auth (derived from key) and required for session auth. `region` pins the SES region; on the first domain it locks the project, after that it must match the project's region." + "description": "A campaign as exposed on the v1 API." }, - "CreateTemplate": { + "CampaignV1Create": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, - "maxLength": 100 + "maxLength": 200 }, "description": { "type": "string", @@ -1515,16 +1852,17 @@ }, "from": { "type": "string", - "format": "email" + "format": "email", + "description": "Sender address. Its domain must be verified for this project." }, - "fromName": { + "from_name": { "type": [ "string", "null" ], "maxLength": 100 }, - "replyTo": { + "reply_to": { "type": [ "string", "null" @@ -1539,23 +1877,42 @@ "HEADLESS" ], "default": "MARKETING" + }, + "audience_type": { + "type": "string", + "enum": [ + "ALL", + "FILTERED", + "SEGMENT" + ], + "description": "`ALL` — every subscribed contact. `FILTERED` — the contacts matching `audience_condition`. `SEGMENT` — the members of `segment_id`." + }, + "audience_condition": { + "type": "object", + "additionalProperties": {}, + "description": "Filter condition: `{ logic: \"AND\" | \"OR\", groups: [{ filters: [{ field, operator, value?, unit? }], conditions?: }] }`. `field` addresses a contact column or a `customFields.` path; `operator` is one of the segment operators (equals, notEquals, contains, greaterThan, lessThan, within, exists, …). Groups combine with `logic`; filters inside one group always combine with AND." + }, + "segment_id": { + "type": "string", + "format": "uuid" } }, "required": [ "name", "subject", "body", - "from" + "from", + "audience_type" ], - "description": "Body for POST /api/templates." + "description": "Body for POST /api/v1/campaigns. `segment_id` is required when `audience_type` is `SEGMENT`, and `audience_condition` is required when it is `FILTERED`." }, - "UpdateTemplate": { + "CampaignV1Update": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, - "maxLength": 100 + "maxLength": 200 }, "description": { "type": "string", @@ -1571,16 +1928,17 @@ }, "from": { "type": "string", - "format": "email" + "format": "email", + "description": "Sender address. Its domain must be verified for this project." }, - "fromName": { + "from_name": { "type": [ "string", "null" ], "maxLength": 100 }, - "replyTo": { + "reply_to": { "type": [ "string", "null" @@ -1594,154 +1952,5543 @@ "MARKETING", "HEADLESS" ] + }, + "audience_type": { + "type": "string", + "enum": [ + "ALL", + "FILTERED", + "SEGMENT" + ] + }, + "audience_condition": { + "type": "object", + "additionalProperties": {}, + "description": "Filter condition: `{ logic: \"AND\" | \"OR\", groups: [{ filters: [{ field, operator, value?, unit? }], conditions?: }] }`. `field` addresses a contact column or a `customFields.` path; `operator` is one of the segment operators (equals, notEquals, contains, greaterThan, lessThan, within, exists, …). Groups combine with `logic`; filters inside one group always combine with AND." + }, + "segment_id": { + "type": "string", + "format": "uuid" } }, - "description": "Body for PATCH /api/templates/{id}." + "description": "Body for PATCH /api/v1/campaigns/{id}. All fields optional." }, - "CreateWebhook": { + "CampaignV1Deleted": { "type": "object", "properties": { - "url": { + "id": { "type": "string", - "format": "uri" + "format": "uuid" }, - "eventTypes": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "email.sent", - "email.delivered", - "email.opened", - "email.clicked", - "email.bounced", - "email.complained", - "email.failed", - "contact.created", - "contact.unsubscribed", - "contacts.bulk_created" - ] - }, - "minItems": 1 + "deleted": { + "type": "boolean", + "enum": [ + true + ] } }, "required": [ - "url", - "eventTypes" + "id", + "deleted" ], - "description": "Body for POST /api/webhooks — register a user webhook for one or more events." + "description": "Acknowledgement that a campaign was deleted." }, - "UpdateWebhook": { + "CampaignV1Send": { "type": "object", "properties": { - "url": { + "scheduled_for": { "type": "string", - "format": "uri" + "format": "date-time", + "description": "RFC 3339 timestamp, strictly in the future. Omit to start sending immediately." + } + }, + "description": "Body for POST /api/v1/campaigns/{id}/send." + }, + "CampaignV1Stats": { + "type": "object", + "properties": { + "total_recipients": { + "type": "integer" }, - "eventTypes": { + "sent": { + "type": "integer" + }, + "delivered": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + }, + "bounced": { + "type": "integer" + }, + "open_rate": { + "type": "number" + }, + "click_rate": { + "type": "number" + }, + "bounce_rate": { + "type": "number" + }, + "delivery_rate": { + "type": "number" + } + }, + "required": [ + "total_recipients", + "sent", + "delivered", + "opened", + "clicked", + "bounced", + "open_rate", + "click_rate", + "bounce_rate", + "delivery_rate" + ], + "description": "Materialized delivery and engagement counters for one campaign." + }, + "SegmentV1List": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "type": "string", - "enum": [ - "email.sent", - "email.delivered", - "email.opened", - "email.clicked", - "email.bounced", - "email.complained", - "email.failed", - "contact.created", - "contact.unsubscribed", - "contacts.bulk_created" - ] - }, - "minItems": 1 + "$ref": "#/components/schemas/SegmentV1" + } }, - "status": { + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of segments." + }, + "SegmentV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { "type": "string", "enum": [ - "ACTIVE", - "PAUSED", - "DISABLED" + "DYNAMIC", + "STATIC" ] + }, + "condition": { + "type": "object", + "additionalProperties": {}, + "description": "Filter condition: `{ logic: \"AND\" | \"OR\", groups: [{ filters: [{ field, operator, value?, unit? }], conditions?: }] }`. `field` addresses a contact column or a `customFields.` path; `operator` is one of the segment operators (equals, notEquals, contains, greaterThan, lessThan, within, exists, …). Groups combine with `logic`; filters inside one group always combine with AND." + }, + "track_membership": { + "type": "boolean" + }, + "member_count": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" } }, - "description": "Body for PATCH /api/webhooks/{id}." + "required": [ + "id", + "name", + "description", + "type", + "condition", + "track_membership", + "member_count", + "created_at", + "updated_at" + ], + "description": "A segment as exposed on the v1 API." }, - "AddSuppression": { + "SegmentV1Create": { "type": "object", "properties": { - "email": { + "name": { "type": "string", - "format": "email" + "minLength": 1, + "maxLength": 100 }, - "reason": { + "description": { + "type": "string", + "maxLength": 500 + }, + "type": { "type": "string", "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" + "DYNAMIC", + "STATIC" ], - "default": "MANUAL" + "default": "DYNAMIC" + }, + "condition": { + "type": "object", + "additionalProperties": {}, + "description": "Filter condition: `{ logic: \"AND\" | \"OR\", groups: [{ filters: [{ field, operator, value?, unit? }], conditions?: }] }`. `field` addresses a contact column or a `customFields.` path; `operator` is one of the segment operators (equals, notEquals, contains, greaterThan, lessThan, within, exists, …). Groups combine with `logic`; filters inside one group always combine with AND." + }, + "track_membership": { + "type": "boolean", + "default": false, + "description": "Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition." } }, "required": [ - "email" + "name" ], - "description": "Body for POST /api/suppression — manually add an email to the suppression list." + "description": "Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`." }, - "TrackEvent": { + "SegmentV1Update": { "type": "object", "properties": { - "event": { + "name": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "condition": { + "type": "object", + "additionalProperties": {}, + "description": "Filter condition: `{ logic: \"AND\" | \"OR\", groups: [{ filters: [{ field, operator, value?, unit? }], conditions?: }] }`. `field` addresses a contact column or a `customFields.` path; `operator` is one of the segment operators (equals, notEquals, contains, greaterThan, lessThan, within, exists, …). Groups combine with `logic`; filters inside one group always combine with AND." + }, + "track_membership": { + "type": "boolean" + } + }, + "description": "Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment." + }, + "SegmentV1Deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "id", + "deleted" + ], + "description": "Acknowledgement that a segment was deleted." + }, + "SegmentContactV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SegmentContactV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of the contacts belonging to a segment." + }, + "SegmentContactV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string" + }, + "subscribed": { + "type": "boolean" + }, + "custom_fields": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "email", + "subscribed", + "custom_fields", + "created_at" + ], + "description": "A contact belonging to a segment." + }, + "WorkflowV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of workflows." + }, + "WorkflowV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enabled": { + "type": "boolean" + }, + "trigger_type": { + "type": "string", + "enum": [ + "EVENT", + "MANUAL", + "SCHEDULE" + ] + }, + "event_name": { + "type": [ + "string", + "null" + ], + "description": "Trigger event for `EVENT` workflows; null for the other trigger types." + }, + "allow_reentry": { + "type": "boolean" + }, + "max_executions_per_hour": { + "type": [ + "integer", + "null" + ] + }, + "version": { + "type": "integer", + "description": "Incremented on every structural (step/transition) change." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "description", + "enabled", + "trigger_type", + "event_name", + "allow_reentry", + "max_executions_per_hour", + "version", + "created_at", + "updated_at" + ], + "description": "An automation workflow as exposed on the v1 API." + }, + "WorkflowCreateV1": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 1000 + }, + "event_name": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "The custom event that starts this workflow, e.g. `user.signup`." + }, + "enabled": { + "type": "boolean", + "description": "Workflows are created disabled. A workflow can only be enabled once every step is configured." + }, + "allow_reentry": { + "type": "boolean" + } + }, + "required": [ + "name", + "event_name" + ], + "description": "Body for POST /api/v1/workflows." + }, + "WorkflowUpdateV1": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 1000 + }, + "event_name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "enabled": { + "type": "boolean" + }, + "allow_reentry": { + "type": "boolean" + }, + "max_executions_per_hour": { + "type": [ + "integer", + "null" + ], + "exclusiveMinimum": 0, + "description": "Per-workflow start rate cap. `null` removes the cap." + } + }, + "description": "Body for PATCH /api/v1/workflows/{id}. Every field is optional; omitted fields are left unchanged. Changing the trigger while executions are running answers 409." + }, + "WorkflowDeletedV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "id", + "deleted" + ], + "description": "Confirmation that a workflow was deleted." + }, + "WorkflowExecutionV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowExecutionV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of workflow executions, newest first." + }, + "WorkflowExecutionV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "workflow_id": { + "type": "string", + "format": "uuid" + }, + "contact_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "RUNNING", + "WAITING", + "COMPLETED", + "EXITED", + "FAILED", + "CANCELLED" + ] + }, + "current_step_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "exit_reason": { + "type": [ + "string", + "null" + ] + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "required": [ + "id", + "workflow_id", + "contact_id", + "status", + "current_step_id", + "exit_reason", + "started_at", + "completed_at" + ], + "description": "One contact's run through a workflow." + }, + "WorkflowExecutionStartV1": { + "type": "object", + "properties": { + "contact_id": { + "type": "string", + "format": "uuid", + "description": "Contact to enter the workflow. Must belong to this project." + }, + "context": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "description": "Extra variables merged into the contact's data for this run." + } + }, + "required": [ + "contact_id" + ], + "description": "Body for POST /api/v1/workflows/{id}/executions." + }, + "WorkflowStatsV1": { + "type": "object", + "properties": { + "workflow_id": { + "type": "string", + "format": "uuid" + }, + "total": { + "type": "integer" + }, + "by_status": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "Execution counts keyed by status; a status with no executions is absent." + }, + "completion_rate": { + "type": [ + "number", + "null" + ], + "description": "Completed ÷ finished executions (0–1). Null until at least one execution has finished." + }, + "avg_duration_ms": { + "type": [ + "number", + "null" + ] + }, + "emails": { + "type": "object", + "properties": { + "sent": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + } + }, + "required": [ + "sent", + "opened", + "clicked" + ] + }, + "conversions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "event_name": { + "type": "string" + }, + "count": { + "type": "integer" + } + }, + "required": [ + "goal_id", + "name", + "event_name", + "count" + ] + } + } + }, + "required": [ + "workflow_id", + "total", + "by_status", + "completion_rate", + "avg_duration_ms", + "emails", + "conversions" + ], + "description": "Execution, email and conversion totals for one workflow." + }, + "ListSubscribe": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "data": { + "type": "object", + "additionalProperties": {}, + "description": "Custom fields to upsert onto the contact as part of subscribing." + }, + "allowResubscribe": { + "type": "boolean", + "default": false, + "description": "Permission to reverse an earlier opt-out. When the email already has an UNSUBSCRIBED membership on this list, the call fails with 409 RESUBSCRIBE_CONFIRMATION_REQUIRED unless this is `true`. Send `true` only when the contact is acting for themselves — a public subscribe form they submitted is affirmative consent — never for an operator-initiated add." + } + }, + "required": [ + "email" + ], + "description": "Body for POST /api/lists/{id}/subscribe." + }, + "ListUnsubscribe": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ], + "description": "Body for POST /api/lists/{id}/unsubscribe." + }, + "AddDomainBody": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "format": "uuid" + }, + "domain": { + "type": "string", + "minLength": 3, + "maxLength": 253 + }, + "region": { + "type": "string", + "enum": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ], + "description": "Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region." + } + }, + "required": [ + "domain" + ], + "description": "Body for POST /api/domains. `projectId` is optional for API-key auth (derived from key) and required for session auth. `region` pins the SES region; on the first domain it locks the project, after that it must match the project's region." + }, + "CreateTemplate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string", + "format": "email" + }, + "fromName": { + "type": [ + "string", + "null" + ], + "maxLength": 100 + }, + "replyTo": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "type": { + "type": "string", + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "HEADLESS" + ], + "default": "MARKETING" + } + }, + "required": [ + "name", + "subject", + "body", + "from" + ], + "description": "Body for POST /api/templates." + }, + "UpdateTemplate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string", + "format": "email" + }, + "fromName": { + "type": [ + "string", + "null" + ], + "maxLength": 100 + }, + "replyTo": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "type": { + "type": "string", + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "HEADLESS" + ] + } + }, + "description": "Body for PATCH /api/templates/{id}." + }, + "CreateWebhook": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "email.sent", + "email.delivered", + "email.opened", + "email.clicked", + "email.bounced", + "email.complained", + "email.failed", + "contact.created", + "contact.unsubscribed", + "contacts.bulk_created" + ] + }, + "minItems": 1 + } + }, + "required": [ + "url", + "eventTypes" + ], + "description": "Body for POST /api/webhooks — register a user webhook for one or more events." + }, + "UpdateWebhook": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "email.sent", + "email.delivered", + "email.opened", + "email.clicked", + "email.bounced", + "email.complained", + "email.failed", + "contact.created", + "contact.unsubscribed", + "contacts.bulk_created" + ] + }, + "minItems": 1 + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "PAUSED", + "DISABLED" + ] + } + }, + "description": "Body for PATCH /api/webhooks/{id}." + }, + "AddSuppression": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "reason": { + "type": "string", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], + "default": "MANUAL" + } + }, + "required": [ + "email" + ], + "description": "Body for POST /api/suppression — manually add an email to the suppression list." + }, + "TrackEvent": { + "type": "object", + "properties": { + "event": { + "type": "string", + "minLength": 1 + }, + "email": { + "type": "string", + "format": "email" + }, + "subscribed": { + "type": "boolean" + }, + "data": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } + }, + "required": [ + "event", + "email" + ], + "description": "Body for POST /api/track — record a custom event for a contact." + }, + "EventV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of events, newest first." + }, + "EventV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "contact_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "email_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "data": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The payload recorded with the event, or null." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "contact_id", + "email_id", + "data", + "created_at" + ], + "description": "A recorded custom event." + }, + "EventTrackV1": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Event name, e.g. `user.signup`." + }, + "contact_id": { + "type": "string", + "format": "uuid", + "description": "Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event." + }, + "data": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "description": "Arbitrary event payload." + } + }, + "required": [ + "name" + ], + "description": "Body for POST /api/v1/events." + }, + "EventNamesV1": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "data" + ], + "description": "Every distinct event name in the project, most frequent first." + }, + "EventStatsV1": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "count": { + "type": "integer" + } + }, + "required": [ + "name", + "count" + ] + } + }, + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" + } + }, + "required": [ + "data", + "window" + ], + "description": "Per-name event counts over the applied window." + }, + "AnalyticsWindowV1": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "from", + "to" + ], + "description": "The time range this response was computed over, after the 90-day clamp." + }, + "AnalyticsTimeseriesV1": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "emails": { + "type": "integer" + }, + "delivered": { + "type": "integer" + }, + "opens": { + "type": "integer" + }, + "clicks": { + "type": "integer" + }, + "bounces": { + "type": "integer" + } + }, + "required": [ + "date", + "emails", + "delivered", + "opens", + "clicks", + "bounces" + ] + } + }, + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" + } + }, + "required": [ + "data", + "window" + ], + "description": "Daily email counters across the window. Every day in range is present, zero-filled." + }, + "AnalyticsCampaignStatsV1": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "active": { + "type": "integer", + "description": "Campaigns in DRAFT or SCHEDULED." + }, + "completed": { + "type": "integer" + }, + "average_open_rate": { + "type": "number", + "description": "Percentage, one decimal place." + }, + "average_click_rate": { + "type": "number" + }, + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" + } + }, + "required": [ + "total", + "active", + "completed", + "average_open_rate", + "average_click_rate", + "window" + ], + "description": "Campaign counters and engagement over the window." + }, + "AnalyticsTopCampaignsV1": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "subject": { + "type": "string" + }, + "sent": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + }, + "open_rate": { + "type": "number" + }, + "click_rate": { + "type": "number" + } + }, + "required": [ + "id", + "subject", + "sent", + "opened", + "clicked", + "open_rate", + "click_rate" + ] + } + }, + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" + } + }, + "required": [ + "data", + "window" + ], + "description": "Sent campaigns ranked by open rate." + }, + "UsageV1": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "enum": [ + "free", + "pro", + "custom" + ], + "description": "`custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`." + }, + "monthly": { + "type": "object", + "properties": { + "emails_sent": { + "type": "integer" + }, + "limit": { + "type": [ + "integer", + "null" + ], + "description": "Monthly cap on the total. Null when per-category limits govern instead." + }, + "categories": { + "type": "object", + "properties": { + "transactional": { + "type": "object", + "properties": { + "emails_sent": { + "type": "integer" + }, + "limit": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "emails_sent", + "limit" + ] + }, + "campaign": { + "type": "object", + "properties": { + "emails_sent": { + "type": "integer" + }, + "limit": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "emails_sent", + "limit" + ] + }, + "workflow": { + "type": "object", + "properties": { + "emails_sent": { + "type": "integer" + }, + "limit": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "emails_sent", + "limit" + ] + }, + "inbound": { + "type": "object", + "properties": { + "emails_sent": { + "type": "integer" + }, + "limit": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "emails_sent", + "limit" + ] + } + }, + "required": [ + "transactional", + "campaign", + "workflow", + "inbound" + ] + } + }, + "required": [ + "emails_sent", + "limit", + "categories" + ] + }, + "daily": { + "type": "object", + "properties": { + "emails_sent": { + "type": [ + "integer", + "null" + ], + "description": "Today's sends. Null when the counter could not be read." + }, + "limit": { + "type": "integer" + }, + "trust_tier": { + "type": "string", + "enum": [ + "NEW", + "ESTABLISHED", + "TRUSTED" + ] + } + }, + "required": [ + "emails_sent", + "limit", + "trust_tier" + ] + } + }, + "required": [ + "plan", + "monthly", + "daily" + ], + "description": "Current email usage against the limits that are actually enforced." + }, + "VerifyEmail": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ], + "description": "Body for POST /api/verify — validate email syntax, MX, disposable, etc." + } + }, + "parameters": {} + }, + "paths": { + "/api/v1/campaigns": { + "get": { + "operationId": "v1ListCampaigns", + "tags": [ + "Campaigns" + ], + "summary": "List campaigns", + "description": "Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.\n\nUnlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Campaign list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "post": { + "operationId": "v1CreateCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Create a campaign", + "description": "Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything.\n\nThe `from` address is checked against this project's verified domains before the campaign is written, so a campaign never exists with a sender it cannot use.\n\n`segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing.\n\nRequires the `campaigns:write` scope — Create, edit, schedule, and send your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." + }, + "required": false, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", + "name": "Idempotency-Key", + "in": "header" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Create" + } + } + } + }, + "responses": { + "201": { + "description": "Campaign created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — `segment_id` names a segment that does not belong to this project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — the request did not match the schema; or `idempotency_key_reused` — this `Idempotency-Key` was already spent on a request with a different body. A key names ONE request, so it is never silently served another one's result: send a new key.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}": { + "get": { + "operationId": "v1GetCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Retrieve a campaign", + "description": "Fetch one campaign, including its materialized delivery counters.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The campaign", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "patch": { + "operationId": "v1UpdateCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Update a campaign", + "description": "Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400.\n\nChanging `from` re-verifies the sender domain. Changing the audience on a `DRAFT` campaign schedules a recipient recount, so `stats.total_recipients` converges shortly after the response.\n\nRequires the `campaigns:write` scope — Create, edit, schedule, and send your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Update" + } + } + } + }, + "responses": { + "200": { + "description": "The updated campaign", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "400": { + "description": "`validation_error` — the campaign is not in an editable status, or the segment change is not allowed.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "delete": { + "operationId": "v1DeleteCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Delete a campaign", + "description": "Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight.\n\nRequires the `campaigns:write` scope — Create, edit, schedule, and send your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Campaign deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Deleted" + } + } + } + }, + "400": { + "description": "`validation_error` — only `DRAFT` campaigns can be deleted.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/send": { + "post": { + "operationId": "v1SendCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Send or schedule a campaign", + "description": "Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now.\n\n**Send an `Idempotency-Key`.** This is the operation that cannot be undone: a retry without one starts a second fan-out over the same audience. The key is scoped to this campaign, so the same key on a different campaign is a `422 idempotency_key_reused` rather than a replay of the first campaign's response.\n\nAnswers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit.\n\nRequires the `campaigns:write` scope — Create, edit, schedule, and send your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." + }, + "required": false, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", + "name": "Idempotency-Key", + "in": "header" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Send" + } + } + } + }, + "responses": { + "200": { + "description": "The campaign, now `SENDING` or `SCHEDULED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "400": { + "description": "`validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — the request did not match the schema; or `idempotency_key_reused` — this `Idempotency-Key` was already spent on a request with a different body. A key names ONE request, so it is never silently served another one's result: send a new key.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/cancel": { + "post": { + "operationId": "v1CancelCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Cancel a campaign", + "description": "Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body.\n\nLike every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request.\n\nRequires the `campaigns:write` scope — Create, edit, schedule, and send your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The cancelled campaign", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "400": { + "description": "`validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/pause": { + "post": { + "operationId": "v1PauseCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Pause a sending campaign", + "description": "Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body.\n\nRequires the `campaigns:write` scope — Create, edit, schedule, and send your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The paused campaign", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "400": { + "description": "`validation_error` — only a `SENDING` campaign can be paused.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/resume": { + "post": { + "operationId": "v1ResumeCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Resume a paused campaign", + "description": "Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body.\n\nRequires the `campaigns:write` scope — Create, edit, schedule, and send your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The resumed campaign", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "400": { + "description": "`validation_error` — only a `PAUSED` campaign can be resumed.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/stats": { + "get": { + "operationId": "v1GetCampaignStats", + "tags": [ + "Campaigns" + ], + "summary": "Retrieve campaign statistics", + "description": "Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight.\n\nRates are percentages (0–100) against `sent`, and are 0 before anything has been sent.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Campaign statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Stats" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/segments": { + "get": { + "operationId": "v1ListSegments", + "tags": [ + "Segments" + ], + "summary": "List segments", + "description": "Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page.\n\n`member_count` is materialized on each segment, so listing segments never fans out into one count per segment — it is refreshed as membership changes rather than computed on read.\n\nRequires the `segments:read` scope — View your segments and who belongs to them.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Segment list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "post": { + "operationId": "v1CreateSegment", + "tags": [ + "Segments" + ], + "summary": "Create a segment", + "description": "Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later.\n\nA `DYNAMIC` segment requires a `condition`, which is validated and evaluated during the request: the response's `member_count` tells you immediately how many contacts the filter actually matches.\n\nRequires the `segments:write` scope — Create, edit, and delete your segments.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1Create" + } + } + } + }, + "responses": { + "201": { + "description": "Segment created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1" + } + } + } + }, + "400": { + "description": "`validation_error` — a `DYNAMIC` segment was submitted without a `condition`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/segments/{id}": { + "get": { + "operationId": "v1GetSegment", + "tags": [ + "Segments" + ], + "summary": "Retrieve a segment", + "description": "Fetch one segment, including its saved `condition` and materialized `member_count`.\n\nRequires the `segments:read` scope — View your segments and who belongs to them.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The segment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "patch": { + "operationId": "v1UpdateSegment", + "tags": [ + "Segments" + ], + "summary": "Update a segment", + "description": "Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list.\n\n`type` is not accepted here — see the create operation.\n\nRequires the `segments:write` scope — Create, edit, and delete your segments.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1Update" + } + } + } + }, + "responses": { + "200": { + "description": "The updated segment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "delete": { + "operationId": "v1DeleteSegment", + "tags": [ + "Segments" + ], + "summary": "Delete a segment", + "description": "Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first.\n\nRequires the `segments:write` scope — Create, edit, and delete your segments.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Segment deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1Deleted" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — the segment is still used by one or more active campaigns.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/segments/{id}/contacts": { + "get": { + "operationId": "v1ListSegmentContacts", + "tags": [ + "Segments" + ], + "summary": "List the contacts in a segment", + "description": "Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now.\n\nCursors from this endpoint are not interchangeable with cursors from other list endpoints — the ordering differs — and pairing one with the wrong endpoint answers 422 rather than silently paging a different set.\n\nRequires the `segments:read` scope — View your segments and who belongs to them.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Segment member list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentContactV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows": { + "get": { + "operationId": "v1ListWorkflows", + "tags": [ + "Workflows" + ], + "summary": "List workflows", + "description": "Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.\n\nUnlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Workflow list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "post": { + "operationId": "v1CreateWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Create a workflow", + "description": "Creates an event-triggered workflow with a single trigger step. The rest of the graph (emails, delays, conditions) is built in the dashboard, so a workflow is created disabled and stays inert until it has steps to run.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowCreateV1" + } + } + } + }, + "responses": { + "201": { + "description": "Workflow created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/{id}": { + "get": { + "operationId": "v1GetWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Retrieve a workflow", + "description": "The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Workflow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "patch": { + "operationId": "v1UpdateWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Update a workflow", + "description": "Sparse update — omitted fields are left unchanged.\n\nTwo state rules apply: the trigger (`event_name`) cannot be changed while the workflow has running executions (409), and `enabled: true` is refused while any step is still unconfigured (422), because an enabled workflow accepts contacts immediately and would otherwise fail only once one reached the broken step.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowUpdateV1" + } + } + } + }, + "responses": { + "200": { + "description": "Updated workflow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — the trigger cannot be changed while executions are running.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "delete": { + "operationId": "v1DeleteWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Delete a workflow", + "description": "Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Workflow deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowDeletedV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — the workflow still has running executions.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/{id}/executions": { + "get": { + "operationId": "v1ListWorkflowExecutions", + "tags": [ + "Workflows" + ], + "summary": "List a workflow's executions", + "description": "One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "RUNNING", + "WAITING", + "COMPLETED", + "EXITED", + "FAILED", + "CANCELLED" + ], + "description": "Return only executions in this state." + }, + "required": false, + "description": "Return only executions in this state.", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Execution list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "post": { + "operationId": "v1StartWorkflowExecution", + "tags": [ + "Workflows" + ], + "summary": "Start a workflow for a contact", + "description": "Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished.\n\n409 when the workflow's re-entry policy already accounts for this contact; 429 when the workflow's own `max_executions_per_hour` cap is reached.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionStartV1" + } + } + } + }, + "responses": { + "201": { + "description": "Execution started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no such workflow, or no such contact in this project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — the contact already has an execution and re-entry is not allowed.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/executions/{execution_id}/cancel": { + "post": { + "operationId": "v1CancelWorkflowExecution", + "tags": [ + "Workflows" + ], + "summary": "Cancel a workflow execution", + "description": "Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow execution id." + }, + "required": true, + "description": "Workflow execution id.", + "name": "execution_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Cancelled execution", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no execution with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/{id}/stats": { + "get": { + "operationId": "v1GetWorkflowStats", + "tags": [ + "Workflows" + ], + "summary": "Retrieve workflow statistics", + "description": "Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Only count executions started at or after this instant (ISO 8601). Defaults to all time." + }, + "required": false, + "description": "Only count executions started at or after this instant (ISO 8601). Defaults to all time.", + "name": "from", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Workflow statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowStatsV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/emails": { + "post": { + "operationId": "sendEmail", + "tags": [ + "Emails" + ], + "summary": "Send a single transactional email", + "description": "Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." + }, + "required": false, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", + "name": "Idempotency-Key", + "in": "header" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendEmail" + } + } + } + }, + "responses": { + "200": { + "description": "Email accepted / sent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendEmailResponse" + } + } + } + }, + "400": { + "description": "Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "`IDEMPOTENCY_KEY_REUSED` — this `Idempotency-Key` was already used for a request with a different body. Reuse a key only to retry the identical request; otherwise send a new key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "`CONTENT_REVIEW_UNAVAILABLE` — content review could not run for this new account, so the message was not accepted. Safe to retry after a short delay.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "get": { + "operationId": "listEmails", + "tags": [ + "Emails" + ], + "summary": "List emails", + "description": "List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "tag", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "PENDING", + "SENT", + "DELIVERED", + "OPENED", + "CLICKED", + "BOUNCED", + "COMPLAINED", + "FAILED" + ] + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "from", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Email list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailListResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/emails/{id}": { + "get": { + "operationId": "getEmail", + "tags": [ + "Emails" + ], + "summary": "Get a single email", + "description": "Fetch one email along with its delivery events.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Email", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailGetResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/emails/batch": { + "post": { + "operationId": "sendEmailBatch", + "tags": [ + "Emails" + ], + "summary": "Send a batch of emails", + "description": "Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array.\n\nThe whole batch is ONE idempotent unit: an `Idempotency-Key` replayed with the same entry list replays the same per-index results, and replaying it with an edited list answers `422 IDEMPOTENCY_KEY_REUSED` rather than returning results for indexes the new body no longer has.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "required": false, + "name": "Idempotency-Key", + "in": "header" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchSendBody" + } + } + } + }, + "responses": { + "200": { + "description": "All entries sent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchSendResponse" + } + } + } + }, + "207": { + "description": "Partial success — at least one entry failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchSendResponse" + } + } + } + }, + "400": { + "description": "Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "`IDEMPOTENCY_KEY_REUSED` — this `Idempotency-Key` was already used for a request with a different body. Reuse a key only to retry the identical request; otherwise send a new key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "`CONTENT_REVIEW_UNAVAILABLE` — content review could not run for this new account, so the message was not accepted. Safe to retry after a short delay.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/emails/{id}/schedule": { + "delete": { + "operationId": "cancelScheduledEmail", + "tags": [ + "Emails" + ], + "summary": "Cancel a scheduled (still-PENDING) email", + "description": "Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Email cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailGetResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Email already past PENDING", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/contacts": { + "get": { + "operationId": "listContacts", + "tags": [ + "Contacts" + ], + "summary": "List contacts", + "description": "Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "search", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + }, + "required": false, + "name": "subscribed", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Contact list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactListResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "createContact", + "tags": [ + "Contacts" + ], + "summary": "Create a contact", + "description": "Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateContact" + } + } + } + }, + "responses": { + "201": { + "description": "Contact created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Contact" + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Email already exists for this project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/contacts/upsert": { + "post": { + "operationId": "upsertContact", + "tags": [ + "Contacts" + ], + "summary": "Create or update a contact by email", + "description": "Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateContact" + } + } + } + }, + "responses": { + "200": { + "description": "Contact created or updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Contact" + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/contacts/bulk": { + "post": { + "operationId": "bulkCreateContacts", + "tags": [ + "Contacts" + ], + "summary": "Bulk-create contacts", + "description": "Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactBulkCreateBody" + } + } + } + }, + "responses": { + "200": { + "description": "Bulk-create result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "created": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "index", + "message" + ] + } + } + }, + "required": [ + "created", + "skipped", + "errors" + ] + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "bulkDeleteContacts", + "tags": [ + "Contacts" + ], + "summary": "Bulk-delete contacts", + "description": "Delete up to 1000 contacts in one call. Provide either `ids` or `emails`.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactBulkDeleteBody" + } + } + } + }, + "responses": { + "200": { + "description": "Bulk-delete result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "deleted": { + "type": "integer" + } + }, + "required": [ + "deleted" + ] + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "email": { - "type": "string", - "format": "email" + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "subscribed": { - "type": "boolean" + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "data": { - "type": "object", - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } - }, - "required": [ - "event", - "email" - ], - "description": "Body for POST /api/track — record a custom event for a contact." - }, - "VerifyEmail": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } - }, - "required": [ - "email" - ], - "description": "Body for POST /api/verify — validate email syntax, MX, disposable, etc." + } } }, - "parameters": {} - }, - "paths": { - "/api/emails": { - "post": { - "operationId": "sendEmail", + "/api/contacts/{id}": { + "get": { + "operationId": "getContact", "tags": [ - "Emails" + "Contacts" ], - "summary": "Send a single transactional email", - "description": "Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety.", + "summary": "Get a contact", "security": [ { "ApiKeyAuth": [] @@ -1754,33 +7501,35 @@ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Replay-safety key (24h TTL)." + "format": "uuid" }, - "required": false, - "description": "Replay-safety key (24h TTL).", - "name": "Idempotency-Key", - "in": "header" + "required": true, + "name": "id", + "in": "path" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendEmail" - } - } - } - }, "responses": { "200": { - "description": "Email accepted / sent", + "description": "Contact", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SendEmailResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Contact" + } + }, + "required": [ + "success", + "data" + ] } } } @@ -1815,6 +7564,16 @@ } } }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -1837,13 +7596,13 @@ } } }, - "get": { - "operationId": "listEmails", + "patch": { + "operationId": "updateContact", "tags": [ - "Emails" + "Contacts" ], - "summary": "List emails", - "description": "List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets.", + "summary": "Update a contact", + "description": "Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses.", "security": [ { "ApiKeyAuth": [] @@ -1853,67 +7612,48 @@ } ], "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "tag", - "in": "query" - }, { "schema": { "type": "string", - "enum": [ - "PENDING", - "SENT", - "DELIVERED", - "OPENED", - "CLICKED", - "BOUNCED", - "COMPLAINED", - "FAILED" - ] - }, - "required": false, - "name": "status", - "in": "query" - }, - { - "schema": { - "type": "string" + "format": "uuid" }, - "required": false, - "name": "from", - "in": "query" + "required": true, + "name": "id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateContactBody" + } + } + } + }, "responses": { "200": { - "description": "Email list", + "description": "Updated contact", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailListResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Contact" + } + }, + "required": [ + "success", + "data" + ] } } } @@ -1948,6 +7688,26 @@ } } }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -1969,16 +7729,14 @@ } } } - } - }, - "/api/emails/{id}": { - "get": { - "operationId": "getEmail", + }, + "delete": { + "operationId": "deleteContact", "tags": [ - "Emails" + "Contacts" ], - "summary": "Get a single email", - "description": "Fetch one email along with its delivery events.", + "summary": "Delete a contact", + "description": "Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content).", "security": [ { "ApiKeyAuth": [] @@ -2000,11 +7758,11 @@ ], "responses": { "200": { - "description": "Email", + "description": "Contact deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailGetResponse" + "$ref": "#/components/schemas/IdResponse" } } } @@ -2072,14 +7830,14 @@ } } }, - "/api/emails/batch": { + "/api/lists/{id}/subscribe": { "post": { - "operationId": "sendEmailBatch", + "operationId": "subscribeToList", "tags": [ - "Emails" + "Lists" ], - "summary": "Send a batch of emails", - "description": "Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array.", + "summary": "Subscribe a contact to a list", + "description": "Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm?token=` to the contact itself.\n\nAccepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form.\n\n**Re-subscribing after an opt-out.** If the email already holds an `UNSUBSCRIBED` membership on this list, the call fails with `409 RESUBSCRIBE_CONFIRMATION_REQUIRED` unless the body sets `allowResubscribe: true`. Reversing an opt-out is a consent decision, so it is never the default — set the flag only when the contact themselves asked to be re-subscribed.\n\n`previousStatus` reports the membership's status before the call (`null` when it did not exist); prefer it over `created` when describing what changed, since `created: false` is equally true for an unchanged membership and for a reactivated one.", "security": [ { "ApiKeyAuth": [] @@ -2093,11 +7851,12 @@ "schema": { "type": "string", "minLength": 1, - "maxLength": 255 + "description": "List id." }, - "required": false, - "name": "Idempotency-Key", - "in": "header" + "required": true, + "description": "List id.", + "name": "id", + "in": "path" } ], "requestBody": { @@ -2105,34 +7864,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchSendBody" + "$ref": "#/components/schemas/ListSubscribe" } } } }, "responses": { "200": { - "description": "All entries sent", + "description": "Contact subscribed, or an existing membership returned unchanged", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchSendResponse" + "$ref": "#/components/schemas/ListSubscribeResponse" } } } }, - "207": { - "description": "Partial success — at least one entry failed", + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchSendResponse" + "$ref": "#/components/schemas/Error" } } } }, - "400": { - "description": "Validation error", + "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { @@ -2141,8 +7900,8 @@ } } }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { @@ -2151,8 +7910,28 @@ } } }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "The contact previously unsubscribed from this list and `allowResubscribe` was not set. `error.code` is `RESUBSCRIBE_CONFIRMATION_REQUIRED` and `error.details.previousStatus` is `UNSUBSCRIBED`. Retry with `allowResubscribe: true` once the contact has consented.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", "content": { "application/json": { "schema": { @@ -2184,14 +7963,14 @@ } } }, - "/api/emails/{id}/schedule": { - "delete": { - "operationId": "cancelScheduledEmail", + "/api/lists/{id}/unsubscribe": { + "post": { + "operationId": "unsubscribeFromList", "tags": [ - "Emails" + "Lists" ], - "summary": "Cancel a scheduled (still-PENDING) email", - "description": "Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING.", + "summary": "Unsubscribe a contact from a list", + "description": "Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds.\n\nOnce a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it.", "security": [ { "ApiKeyAuth": [] @@ -2204,20 +7983,32 @@ { "schema": { "type": "string", - "format": "uuid" + "minLength": 1, + "description": "List id." }, "required": true, + "description": "List id.", "name": "id", "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListUnsubscribe" + } + } + } + }, "responses": { "200": { - "description": "Email cancelled", + "description": "Contact unsubscribed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailGetResponse" + "$ref": "#/components/schemas/ListUnsubscribeResponse" } } } @@ -2262,8 +8053,8 @@ } } }, - "409": { - "description": "Email already past PENDING", + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", "content": { "application/json": { "schema": { @@ -2295,14 +8086,14 @@ } } }, - "/api/contacts": { + "/api/domains": { "get": { - "operationId": "listContacts", + "operationId": "listDomains", "tags": [ - "Contacts" + "Domains" ], - "summary": "List contacts", - "description": "Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`.", + "summary": "List sending domains", + "description": "List all domains for the authenticated project.", "security": [ { "ApiKeyAuth": [] @@ -2311,54 +8102,13 @@ "SessionAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "search", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "true", - "false" - ] - }, - "required": false, - "name": "subscribed", - "in": "query" - } - ], "responses": { "200": { - "description": "Contact list", + "description": "Domain list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactListResponse" + "$ref": "#/components/schemas/DomainListResponse" } } } @@ -2393,16 +8143,6 @@ } } }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -2426,12 +8166,12 @@ } }, "post": { - "operationId": "createContact", + "operationId": "addDomain", "tags": [ - "Contacts" + "Domains" ], - "summary": "Create a contact", - "description": "Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics.", + "summary": "Add a sending domain", + "description": "Register a new domain with SES and persist its DKIM tokens.", "security": [ { "ApiKeyAuth": [] @@ -2445,14 +8185,119 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateContact" + "$ref": "#/components/schemas/AddDomainBody" } } } }, "responses": { "201": { - "description": "Contact created", + "description": "Domain added", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Domain" + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/domains/{id}": { + "get": { + "operationId": "getDomain", + "tags": [ + "Domains" + ], + "summary": "Get a sending domain", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Domain", "content": { "application/json": { "schema": { @@ -2465,7 +8310,7 @@ ] }, "data": { - "$ref": "#/components/schemas/Contact" + "$ref": "#/components/schemas/Domain" } }, "required": [ @@ -2506,18 +8351,8 @@ } } }, - "409": { - "description": "Email already exists for this project", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", + "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { @@ -2547,16 +8382,14 @@ } } } - } - }, - "/api/contacts/upsert": { - "post": { - "operationId": "upsertContact", + }, + "delete": { + "operationId": "deleteDomain", "tags": [ - "Contacts" + "Domains" ], - "summary": "Create or update a contact by email", - "description": "Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code.", + "summary": "Remove a sending domain", + "description": "Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it.", "security": [ { "ApiKeyAuth": [] @@ -2565,38 +8398,24 @@ "SessionAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateContact" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Contact created or updated", + "description": "Domain removed", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Contact" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/SuccessEmpty" } } } @@ -2631,8 +8450,8 @@ } } }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", + "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { @@ -2664,14 +8483,14 @@ } } }, - "/api/contacts/bulk": { + "/api/domains/{id}/verify": { "post": { - "operationId": "bulkCreateContacts", + "operationId": "verifyDomain", "tags": [ - "Contacts" + "Domains" ], - "summary": "Bulk-create contacts", - "description": "Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`.", + "summary": "Trigger SES verification", + "description": "Force a refresh of the domain's SES verification status.", "security": [ { "ApiKeyAuth": [] @@ -2680,19 +8499,20 @@ "SessionAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactBulkCreateBody" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Bulk-create result", + "description": "Verification status", "content": { "application/json": { "schema": { @@ -2705,38 +8525,7 @@ ] }, "data": { - "type": "object", - "properties": { - "created": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "index": { - "type": "integer" - }, - "message": { - "type": "string" - } - }, - "required": [ - "index", - "message" - ] - } - } - }, - "required": [ - "created", - "skipped", - "errors" - ] + "$ref": "#/components/schemas/DomainVerificationStatus" } }, "required": [ @@ -2777,8 +8566,8 @@ } } }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", + "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { @@ -2809,13 +8598,13 @@ } } }, - "delete": { - "operationId": "bulkDeleteContacts", + "get": { + "operationId": "getDomainVerification", "tags": [ - "Contacts" + "Domains" ], - "summary": "Bulk-delete contacts", - "description": "Delete up to 1000 contacts in one call. Provide either `ids` or `emails`.", + "summary": "Read SES verification status", + "description": "Read the current SES verification status without forcing a refresh.", "security": [ { "ApiKeyAuth": [] @@ -2824,19 +8613,20 @@ "SessionAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactBulkDeleteBody" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Bulk-delete result", + "description": "Verification status", "content": { "application/json": { "schema": { @@ -2849,15 +8639,7 @@ ] }, "data": { - "type": "object", - "properties": { - "deleted": { - "type": "integer" - } - }, - "required": [ - "deleted" - ] + "$ref": "#/components/schemas/DomainVerificationStatus" } }, "required": [ @@ -2898,8 +8680,8 @@ } } }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", + "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { @@ -2931,13 +8713,14 @@ } } }, - "/api/contacts/{id}": { + "/api/templates": { "get": { - "operationId": "getContact", + "operationId": "listTemplates", "tags": [ - "Contacts" + "Templates" ], - "summary": "Get a contact", + "summary": "List templates", + "description": "Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject.", "security": [ { "ApiKeyAuth": [] @@ -2947,38 +8730,55 @@ } ], "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, { "schema": { "type": "string", - "format": "uuid" + "minLength": 1 }, - "required": true, - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Contact", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Contact" - } - }, - "required": [ - "success", - "data" - ] + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "search", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "MARKETING", + "TRANSACTIONAL", + "HEADLESS" + ] + }, + "required": false, + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Template list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateListResponse" } } } @@ -3013,8 +8813,8 @@ } } }, - "404": { - "description": "Resource not found", + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", "content": { "application/json": { "schema": { @@ -3045,13 +8845,13 @@ } } }, - "patch": { - "operationId": "updateContact", + "post": { + "operationId": "createTemplate", "tags": [ - "Contacts" + "Templates" ], - "summary": "Update a contact", - "description": "Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses.", + "summary": "Create a template", + "description": "Create a new email template. The `from` domain must already be verified for the project.", "security": [ { "ApiKeyAuth": [] @@ -3060,30 +8860,19 @@ "SessionAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" - } - ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateContactBody" + "$ref": "#/components/schemas/CreateTemplate" } } } }, "responses": { - "200": { - "description": "Updated contact", + "201": { + "description": "Template created", "content": { "application/json": { "schema": { @@ -3096,7 +8885,7 @@ ] }, "data": { - "$ref": "#/components/schemas/Contact" + "$ref": "#/components/schemas/Template" } }, "required": [ @@ -3137,16 +8926,6 @@ } } }, - "404": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { "description": "Validation failed — request body or query parameters did not match the schema", "content": { @@ -3178,14 +8957,15 @@ } } } - }, - "delete": { - "operationId": "deleteContact", + } + }, + "/api/templates/{id}": { + "get": { + "operationId": "getTemplate", "tags": [ - "Contacts" + "Templates" ], - "summary": "Delete a contact", - "description": "Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content).", + "summary": "Get a template", "security": [ { "ApiKeyAuth": [] @@ -3207,11 +8987,26 @@ ], "responses": { "200": { - "description": "Contact deleted", + "description": "Template", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IdResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Template" + } + }, + "required": [ + "success", + "data" + ] } } } @@ -3277,16 +9072,14 @@ } } } - } - }, - "/api/domains": { - "get": { - "operationId": "listDomains", + }, + "patch": { + "operationId": "updateTemplate", "tags": [ - "Domains" + "Templates" ], - "summary": "List sending domains", - "description": "List all domains for the authenticated project.", + "summary": "Update a template", + "description": "Update one or more fields. If `from` changes, the new domain must already be verified.", "security": [ { "ApiKeyAuth": [] @@ -3295,82 +9088,15 @@ "SessionAuth": [] } ], - "responses": { - "200": { - "description": "Domain list", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DomainListResponse" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized — missing or invalid auth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Rate limit or billing limit exceeded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - }, - "post": { - "operationId": "addDomain", - "tags": [ - "Domains" - ], - "summary": "Add a sending domain", - "description": "Register a new domain with SES and persist its DKIM tokens.", - "security": [ - { - "ApiKeyAuth": [] - }, + "parameters": [ { - "SessionAuth": [] + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], "requestBody": { @@ -3378,14 +9104,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddDomainBody" + "$ref": "#/components/schemas/UpdateTemplate" } } } }, "responses": { - "201": { - "description": "Domain added", + "200": { + "description": "Updated template", "content": { "application/json": { "schema": { @@ -3398,7 +9124,7 @@ ] }, "data": { - "$ref": "#/components/schemas/Domain" + "$ref": "#/components/schemas/Template" } }, "required": [ @@ -3439,6 +9165,26 @@ } } }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -3460,15 +9206,14 @@ } } } - } - }, - "/api/domains/{id}": { - "get": { - "operationId": "getDomain", + }, + "delete": { + "operationId": "deleteTemplate", "tags": [ - "Domains" + "Templates" ], - "summary": "Get a sending domain", + "summary": "Delete a template", + "description": "Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign.", "security": [ { "ApiKeyAuth": [] @@ -3490,26 +9235,11 @@ ], "responses": { "200": { - "description": "Domain", + "description": "Template deleted", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Domain" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/IdResponse" } } } @@ -3544,8 +9274,18 @@ } } }, - "404": { - "description": "Resource not found", + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Template still in use", "content": { "application/json": { "schema": { @@ -3575,14 +9315,16 @@ } } } - }, - "delete": { - "operationId": "deleteDomain", + } + }, + "/api/webhooks": { + "get": { + "operationId": "listWebhooks", "tags": [ - "Domains" + "Webhooks" ], - "summary": "Remove a sending domain", - "description": "Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it.", + "summary": "List user webhooks", + "description": "List all user-managed outbound webhooks for the auth'd project (secrets are not returned).", "security": [ { "ApiKeyAuth": [] @@ -3591,24 +9333,13 @@ "SessionAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" - } - ], "responses": { "200": { - "description": "Domain removed", + "description": "Webhook list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuccessEmpty" + "$ref": "#/components/schemas/WebhookListResponse" } } } @@ -3643,16 +9374,6 @@ } } }, - "404": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -3674,16 +9395,14 @@ } } } - } - }, - "/api/domains/{id}/verify": { + }, "post": { - "operationId": "verifyDomain", + "operationId": "createWebhook", "tags": [ - "Domains" + "Webhooks" ], - "summary": "Trigger SES verification", - "description": "Force a refresh of the domain's SES verification status.", + "summary": "Create a webhook", + "description": "Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely.", "security": [ { "ApiKeyAuth": [] @@ -3692,39 +9411,23 @@ "SessionAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhook" + } + } } - ], + }, "responses": { - "200": { - "description": "Verification status", + "201": { + "description": "Webhook created", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/DomainVerificationStatus" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WebhookCreateResponse" } } } @@ -3759,16 +9462,6 @@ } } }, - "404": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -3790,14 +9483,15 @@ } } } - }, + } + }, + "/api/webhooks/{id}": { "get": { - "operationId": "getDomainVerification", + "operationId": "getWebhook", "tags": [ - "Domains" + "Webhooks" ], - "summary": "Read SES verification status", - "description": "Read the current SES verification status without forcing a refresh.", + "summary": "Get a webhook", "security": [ { "ApiKeyAuth": [] @@ -3819,26 +9513,11 @@ ], "responses": { "200": { - "description": "Verification status", + "description": "Webhook", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/DomainVerificationStatus" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WebhookGetResponse" } } } @@ -3904,16 +9583,13 @@ } } } - } - }, - "/api/templates": { - "get": { - "operationId": "listTemplates", + }, + "patch": { + "operationId": "updateWebhook", "tags": [ - "Templates" + "Webhooks" ], - "summary": "List templates", - "description": "Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject.", + "summary": "Update a webhook", "security": [ { "ApiKeyAuth": [] @@ -3923,55 +9599,33 @@ } ], "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "search", - "in": "query" - }, { "schema": { "type": "string", - "enum": [ - "MARKETING", - "TRANSACTIONAL", - "HEADLESS" - ] + "format": "uuid" }, - "required": false, - "name": "type", - "in": "query" + "required": true, + "name": "id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhook" + } + } + } + }, "responses": { "200": { - "description": "Template list", + "description": "Webhook updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateListResponse" + "$ref": "#/components/schemas/WebhookGetResponse" } } } @@ -4006,8 +9660,8 @@ } } }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", + "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { @@ -4038,13 +9692,13 @@ } } }, - "post": { - "operationId": "createTemplate", + "delete": { + "operationId": "deleteWebhook", "tags": [ - "Templates" + "Webhooks" ], - "summary": "Create a template", - "description": "Create a new email template. The `from` domain must already be verified for the project.", + "summary": "Delete a webhook", + "description": "Hard-delete a webhook. Cascades to all WebhookCall rows.", "security": [ { "ApiKeyAuth": [] @@ -4053,38 +9707,24 @@ "SessionAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTemplate" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } - }, + ], "responses": { - "201": { - "description": "Template created", + "200": { + "description": "Webhook deleted", "content": { "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Template" - } - }, - "required": [ - "success", - "data" - ] + "schema": { + "$ref": "#/components/schemas/SuccessEmpty" } } } @@ -4119,8 +9759,8 @@ } } }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", + "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { @@ -4152,13 +9792,14 @@ } } }, - "/api/templates/{id}": { - "get": { - "operationId": "getTemplate", + "/api/webhooks/{id}/rotate-secret": { + "post": { + "operationId": "rotateWebhookSecret", "tags": [ - "Templates" + "Webhooks" ], - "summary": "Get a template", + "summary": "Rotate the webhook signing secret", + "description": "Generate a new shared secret. Returns the new plaintext secret exactly once.", "security": [ { "ApiKeyAuth": [] @@ -4180,26 +9821,11 @@ ], "responses": { "200": { - "description": "Template", + "description": "Secret rotated", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Template" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WebhookRotateSecretResponse" } } } @@ -4265,14 +9891,16 @@ } } } - }, - "patch": { - "operationId": "updateTemplate", + } + }, + "/api/webhooks/{id}/calls": { + "get": { + "operationId": "listWebhookCalls", "tags": [ - "Templates" + "Webhooks" ], - "summary": "Update a template", - "description": "Update one or more fields. If `from` changes, the new domain must already be verified.", + "summary": "List recent webhook calls", + "description": "Cursor-paginated list of recent delivery attempts for a single webhook.", "security": [ { "ApiKeyAuth": [] @@ -4290,40 +9918,34 @@ "required": true, "name": "id", "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateTemplate" - } - } - } - }, "responses": { "200": { - "description": "Updated template", + "description": "Webhook call history", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Template" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WebhookCallsListResponse" } } } @@ -4368,16 +9990,6 @@ } } }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -4399,14 +10011,16 @@ } } } - }, - "delete": { - "operationId": "deleteTemplate", + } + }, + "/api/suppression": { + "get": { + "operationId": "listSuppressions", "tags": [ - "Templates" + "Suppression" ], - "summary": "Delete a template", - "description": "Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign.", + "summary": "List suppressed emails", + "description": "Cursor-paginated list of suppressed addresses. Filter by `reason`.", "security": [ { "ApiKeyAuth": [] @@ -4416,23 +10030,47 @@ } ], "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" + }, { "schema": { "type": "string", - "format": "uuid" + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ] }, - "required": true, - "name": "id", - "in": "path" + "required": false, + "name": "reason", + "in": "query" } ], "responses": { "200": { - "description": "Template deleted", + "description": "Suppression list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IdResponse" + "$ref": "#/components/schemas/SuppressionListResponse" } } } @@ -4467,26 +10105,6 @@ } } }, - "404": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Template still in use", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -4508,16 +10126,14 @@ } } } - } - }, - "/api/webhooks": { - "get": { - "operationId": "listWebhooks", + }, + "post": { + "operationId": "addSuppression", "tags": [ - "Webhooks" + "Suppression" ], - "summary": "List user webhooks", - "description": "List all user-managed outbound webhooks for the auth'd project (secrets are not returned).", + "summary": "Manually add an email to the suppression list", + "description": "The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers.", "security": [ { "ApiKeyAuth": [] @@ -4526,13 +10142,23 @@ "SessionAuth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddSuppression" + } + } + } + }, "responses": { - "200": { - "description": "Webhook list", + "201": { + "description": "Suppression added", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookListResponse" + "$ref": "#/components/schemas/Suppression" } } } @@ -4588,14 +10214,16 @@ } } } - }, - "post": { - "operationId": "createWebhook", + } + }, + "/api/suppression/{email}": { + "get": { + "operationId": "checkSuppression", "tags": [ - "Webhooks" + "Suppression" ], - "summary": "Create a webhook", - "description": "Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely.", + "summary": "Check whether an email is suppressed", + "description": "Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded.", "security": [ { "ApiKeyAuth": [] @@ -4604,23 +10232,25 @@ "SessionAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateWebhook" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "URL-encoded email address" + }, + "required": true, + "description": "URL-encoded email address", + "name": "email", + "in": "path" } - }, + ], "responses": { - "201": { - "description": "Webhook created", + "200": { + "description": "Suppression check result", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookCreateResponse" + "$ref": "#/components/schemas/SuppressionCheckResponse" } } } @@ -4676,15 +10306,14 @@ } } } - } - }, - "/api/webhooks/{id}": { - "get": { - "operationId": "getWebhook", + }, + "delete": { + "operationId": "removeSuppression", "tags": [ - "Webhooks" + "Suppression" ], - "summary": "Get a webhook", + "summary": "Remove an email from the suppression list", + "description": "Idempotent. Silently no-ops if the suppression doesn't exist.", "security": [ { "ApiKeyAuth": [] @@ -4697,23 +10326,17 @@ { "schema": { "type": "string", - "format": "uuid" + "description": "URL-encoded email address" }, "required": true, - "name": "id", + "description": "URL-encoded email address", + "name": "email", "in": "path" } ], "responses": { - "200": { - "description": "Webhook", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookGetResponse" - } - } - } + "204": { + "description": "Suppression removed" }, "400": { "description": "Validation error", @@ -4745,16 +10368,6 @@ } } }, - "404": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -4776,30 +10389,19 @@ } } } - }, - "patch": { - "operationId": "updateWebhook", + } + }, + "/api/track": { + "post": { + "operationId": "trackEvent", "tags": [ - "Webhooks" + "Events" ], - "summary": "Update a webhook", + "summary": "Track a custom event for a contact", + "description": "Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected.", "security": [ { "ApiKeyAuth": [] - }, - { - "SessionAuth": [] - } - ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" } ], "requestBody": { @@ -4807,18 +10409,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateWebhook" + "$ref": "#/components/schemas/TrackEvent" } } } }, "responses": { "200": { - "description": "Webhook updated", + "description": "Event tracked", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookGetResponse" + "$ref": "#/components/schemas/TrackEventResponse" } } } @@ -4853,16 +10455,6 @@ } } }, - "404": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Rate limit or billing limit exceeded", "content": { @@ -4884,14 +10476,16 @@ } } } - }, - "delete": { - "operationId": "deleteWebhook", + } + }, + "/api/v1/events": { + "get": { + "operationId": "v1ListEvents", "tags": [ - "Webhooks" + "Events" ], - "summary": "Delete a webhook", - "description": "Hard-delete a webhook. Cascades to all WebhookCall rows.", + "summary": "List events", + "description": "Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series.\n\nA cursor is bound to the filters it was issued under: pairing page 2's `next_cursor` with a different `event_name` answers 422 rather than returning a page that belongs to neither query.\n\nRequires the `events:read` scope — View the custom events your application has recorded.", "security": [ { "ApiKeyAuth": [] @@ -4901,98 +10495,111 @@ } ], "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, { "schema": { "type": "string", - "format": "uuid" + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." }, - "required": true, - "name": "id", - "in": "path" + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Return only events with this exact name." + }, + "required": false, + "description": "Return only events with this exact name.", + "name": "event_name", + "in": "query" } ], "responses": { "200": { - "description": "Webhook deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SuccessEmpty" - } - } - } - }, - "400": { - "description": "Validation error", + "description": "Event list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EventV1List" } } } }, "401": { - "description": "Unauthorized — missing or invalid auth", + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "404": { - "description": "Resource not found", + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "429": { - "description": "Rate limit or billing limit exceeded", + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "500": { - "description": "Internal server error", + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } } } - } - }, - "/api/webhooks/{id}/rotate-secret": { + }, "post": { - "operationId": "rotateWebhookSecret", + "operationId": "v1TrackEvent", "tags": [ - "Webhooks" + "Events" ], - "summary": "Rotate the webhook signing secret", - "description": "Generate a new shared secret. Returns the new plaintext secret exactly once.", + "summary": "Record an event", + "description": "Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call.\n\n`contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event.\n\nReserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected with 422: they are written by Sendly's own pipeline and accepting them from a caller would corrupt the series segments read.\n\nThis endpoint does NOT require an `Idempotency-Key`. Events are append-only and the highest-volume write on the surface; a duplicate is a data-quality question for the caller, not a money-path hazard.\n\nRequires the `events:write` scope — Record custom events for your contacts. Sending-only (`pk_*`) keys do NOT hold it.", "security": [ { "ApiKeyAuth": [] @@ -5001,84 +10608,83 @@ "SessionAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTrackV1" + } + } } - ], + }, "responses": { - "200": { - "description": "Secret rotated", + "201": { + "description": "Event recorded", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookRotateSecretResponse" + "$ref": "#/components/schemas/EventV1" } } } }, - "400": { - "description": "Validation error", + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "404": { + "description": "`resource_not_found` — no contact with this id in the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "404": { - "description": "Resource not found", + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "429": { - "description": "Rate limit or billing limit exceeded", + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "500": { - "description": "Internal server error", + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } @@ -5086,14 +10692,14 @@ } } }, - "/api/webhooks/{id}/calls": { + "/api/v1/events/names": { "get": { - "operationId": "listWebhookCalls", + "operationId": "v1ListEventNames", "tags": [ - "Webhooks" + "Events" ], - "summary": "List recent webhook calls", - "description": "Cursor-paginated list of recent delivery attempts for a single webhook.", + "summary": "List event names", + "description": "Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume.\n\nRequires the `events:read` scope — View the custom events your application has recorded.", "security": [ { "ApiKeyAuth": [] @@ -5102,103 +10708,63 @@ "SessionAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - } - ], "responses": { "200": { - "description": "Webhook call history", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookCallsListResponse" - } - } - } - }, - "400": { - "description": "Validation error", + "description": "Event names", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EventNamesV1" } } } }, "401": { - "description": "Unauthorized — missing or invalid auth", + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "404": { - "description": "Resource not found", + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "429": { - "description": "Rate limit or billing limit exceeded", + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "500": { - "description": "Internal server error", + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } @@ -5206,14 +10772,14 @@ } } }, - "/api/suppression": { + "/api/v1/events/stats": { "get": { - "operationId": "listSuppressions", + "operationId": "v1GetEventStats", "tags": [ - "Suppression" + "Events" ], - "summary": "List suppressed emails", - "description": "Cursor-paginated list of suppressed addresses. Filter by `reason`.", + "summary": "Retrieve event counts", + "description": "Per-name event counts over a bounded window, most frequent first.\n\nThe window defaults to the last 30 days and never reaches further back than 90: this is a GROUP BY over the highest-volume table in the system, and an all-time answer is not one it can keep giving at scale. A wider request is narrowed rather than refused, and the `window` field states the range actually covered.\n\nRequires the `events:read` scope — View the custom events your application has recorded.", "security": [ { "ApiKeyAuth": [] @@ -5225,108 +10791,105 @@ "parameters": [ { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string" + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." }, "required": false, - "name": "cursor", + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", + "name": "from", "in": "query" }, { "schema": { - "type": "string", - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" - ] + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "End of the window (ISO 8601). Defaults to now." }, "required": false, - "name": "reason", + "description": "End of the window (ISO 8601). Defaults to now.", + "name": "to", "in": "query" } ], "responses": { "200": { - "description": "Suppression list", + "description": "Event counts", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionListResponse" + "$ref": "#/components/schemas/EventStatsV1" } } } }, - "400": { - "description": "Validation error", + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "429": { - "description": "Rate limit or billing limit exceeded", + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "500": { - "description": "Internal server error", + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } } } - }, - "post": { - "operationId": "addSuppression", + } + }, + "/api/v1/analytics/timeseries": { + "get": { + "operationId": "v1GetAnalyticsTimeseries", "tags": [ - "Suppression" + "Analytics" ], - "summary": "Manually add an email to the suppression list", - "description": "The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers.", + "summary": "Retrieve the daily email time series", + "description": "Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling.\n\n`from` defaults to 30 days ago and is clamped to at most 90 days back; `to` defaults to now. A wider request is narrowed rather than refused, and the `window` field states the range actually covered — read it before comparing two responses.\n\nRequires the `analytics:read` scope — View your sending analytics and engagement metrics.", "security": [ { "ApiKeyAuth": [] @@ -5335,73 +10898,93 @@ "SessionAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddSuppression" - } - } + "parameters": [ + { + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." + }, + "required": false, + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", + "name": "from", + "in": "query" + }, + { + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "End of the window (ISO 8601). Defaults to now." + }, + "required": false, + "description": "End of the window (ISO 8601). Defaults to now.", + "name": "to", + "in": "query" } - }, + ], "responses": { - "201": { - "description": "Suppression added", + "200": { + "description": "Daily time series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Suppression" + "$ref": "#/components/schemas/AnalyticsTimeseriesV1" } } } }, - "400": { - "description": "Validation error", + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "429": { - "description": "Rate limit or billing limit exceeded", + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "500": { - "description": "Internal server error", + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } @@ -5409,14 +10992,14 @@ } } }, - "/api/suppression/{email}": { + "/api/v1/analytics/campaigns": { "get": { - "operationId": "checkSuppression", + "operationId": "v1GetCampaignAnalytics", "tags": [ - "Suppression" + "Analytics" ], - "summary": "Check whether an email is suppressed", - "description": "Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded.", + "summary": "Retrieve campaign totals and engagement", + "description": "Campaign counts plus average open and click rates.\n\n`total` and `active` count campaigns CREATED in the window; `completed` counts campaigns SENT in it — so a campaign created earlier and sent inside the window appears only in `completed`. Rates are percentages to one decimal place, averaged over the campaigns sent in the window.\n\n`from` defaults to 30 days ago and is clamped to at most 90 days back; `to` defaults to now. A wider request is narrowed rather than refused, and the `window` field states the range actually covered — read it before comparing two responses.\n\nRequires the `analytics:read` scope — View your sending analytics and engagement metrics.", "security": [ { "ApiKeyAuth": [] @@ -5428,85 +11011,105 @@ "parameters": [ { "schema": { - "type": "string", - "description": "URL-encoded email address" + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." }, - "required": true, - "description": "URL-encoded email address", - "name": "email", - "in": "path" + "required": false, + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", + "name": "from", + "in": "query" + }, + { + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "End of the window (ISO 8601). Defaults to now." + }, + "required": false, + "description": "End of the window (ISO 8601). Defaults to now.", + "name": "to", + "in": "query" } ], "responses": { "200": { - "description": "Suppression check result", + "description": "Campaign statistics", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionCheckResponse" + "$ref": "#/components/schemas/AnalyticsCampaignStatsV1" } } } }, - "400": { - "description": "Validation error", + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "429": { - "description": "Rate limit or billing limit exceeded", + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "500": { - "description": "Internal server error", + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } } } - }, - "delete": { - "operationId": "removeSuppression", + } + }, + "/api/v1/analytics/top-campaigns": { + "get": { + "operationId": "v1ListTopCampaigns", "tags": [ - "Suppression" + "Analytics" ], - "summary": "Remove an email from the suppression list", - "description": "Idempotent. Silently no-ops if the suppression doesn't exist.", + "summary": "List the best-performing campaigns", + "description": "Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page.\n\n`from` defaults to 30 days ago and is clamped to at most 90 days back; `to` defaults to now. A wider request is narrowed rather than refused, and the `window` field states the range actually covered — read it before comparing two responses.\n\nRequires the `analytics:read` scope — View your sending analytics and engagement metrics.", "security": [ { "ApiKeyAuth": [] @@ -5518,65 +11121,101 @@ "parameters": [ { "schema": { - "type": "string", - "description": "URL-encoded email address" + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." }, - "required": true, - "description": "URL-encoded email address", - "name": "email", - "in": "path" + "required": false, + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", + "name": "from", + "in": "query" + }, + { + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "End of the window (ISO 8601). Defaults to now." + }, + "required": false, + "description": "End of the window (ISO 8601). Defaults to now.", + "name": "to", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10 + }, + "required": false, + "name": "limit", + "in": "query" } ], "responses": { - "204": { - "description": "Suppression removed" - }, - "400": { - "description": "Validation error", + "200": { + "description": "Ranked campaigns", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/AnalyticsTopCampaignsV1" } } } }, "401": { - "description": "Unauthorized — missing or invalid auth", + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" } } } }, "429": { - "description": "Rate limit or billing limit exceeded", + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "500": { - "description": "Internal server error", + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } @@ -5584,86 +11223,79 @@ } } }, - "/api/track": { - "post": { - "operationId": "trackEvent", + "/api/v1/usage": { + "get": { + "operationId": "v1GetUsage", "tags": [ - "Events" + "Usage" ], - "summary": "Track a custom event for a contact", - "description": "Record a custom event. Both FULL (`sk_*`) and SENDING_ONLY (`pk_*`) keys are accepted, but reserved system event names are rejected.", + "summary": "Retrieve current usage and limits", + "description": "Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling.\n\nEvery figure is read from an enforcement path, so what this reports and what refuses a send cannot disagree. Correspondingly, nothing else is published — there is no billing period, invoice total or non-email meter here, because the platform meters none of those.\n\nTwo caveats worth reading before you alert on these numbers:\n\n- The windows differ. The monthly counters roll over on the SERVER's calendar month; the daily counter buckets on the UTC date. The two therefore reset at different instants.\n- `monthly.limit` is null once a subscription makes sending metered rather than capped, and also when an operator has set per-category limits — in that case the caps live in `monthly.categories[*].limit`.\n\nRequires the `usage:read` scope — View your usage totals and billing limits.", "security": [ { "ApiKeyAuth": [] + }, + { + "SessionAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TrackEvent" - } - } - } - }, "responses": { "200": { - "description": "Event tracked", + "description": "Current usage", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TrackEventResponse" + "$ref": "#/components/schemas/UsageV1" } } } }, - "400": { - "description": "Validation error", + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "429": { - "description": "Rate limit or billing limit exceeded", + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } }, "500": { - "description": "Internal server error", + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } } diff --git a/tests/support.py b/tests/support.py index 6e747bc..8daa3cf 100644 --- a/tests/support.py +++ b/tests/support.py @@ -33,6 +33,13 @@ def empty_response(status: int = 204) -> httpx.Response: return httpx.Response(status) +def problem_response(status: int, problem: dict[str, object]) -> httpx.Response: + """Build an RFC 9457 ``application/problem+json`` error response.""" + return httpx.Response( + status, json=problem, headers={"content-type": "application/problem+json"} + ) + + class Recorder: """Request handler that records requests and replies with a fixed response.""" @@ -48,3 +55,35 @@ def __call__(self, request: httpx.Request) -> httpx.Response: def request(self) -> httpx.Request: """The first (usually only) recorded request.""" return self.requests[0] + + +class SequenceRecorder: + """Request handler that replies with a queued response per call, in order. + + Used to walk a paginated endpoint. Running past the last queued response + fails the test rather than repeating one, so an auto-paginator that ignores + its stop condition surfaces as an error instead of an infinite loop. + """ + + def __init__(self, *responses: httpx.Response) -> None: + self._responses = list(responses) + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + index = len(self.requests) - 1 + assert index < len(self._responses), ( + f"unexpected request #{index + 1} to {request.url}: only " + f"{len(self._responses)} responses were queued" + ) + return self._responses[index] + + @property + def urls(self) -> list[str]: + """Every requested URL, in call order.""" + return [str(request.url) for request in self.requests] + + +def cursor_page(items: list[object], *, next_cursor: str | None = None) -> dict[str, object]: + """Build an ``/api/v1`` cursor-list envelope: ``{data, has_more, next_cursor}``.""" + return {"data": items, "has_more": next_cursor is not None, "next_cursor": next_cursor} diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..ccdf6ae --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,70 @@ +"""Analytics resource tests (``/api/v1``).""" + +from __future__ import annotations + +import pytest + +from sendly import SendlyPermissionError +from support import Recorder, json_response, make_client, problem_response + +WINDOW = {"from": "2026-08-01", "to": "2026-08-18"} + + +def test_timeseries_forwards_the_window_and_returns_the_bare_body(): + payload = {"data": [{"date": "2026-08-01", "sent": 120}], "window": WINDOW} + rec = Recorder(json_response(200, payload)) + client = make_client(rec) + + result = client.analytics.timeseries({"from": "2026-08-01", "to": "2026-08-18"}) + + assert str(rec.request.url) == ( + "http://localhost/api/v1/analytics/timeseries?from=2026-08-01&to=2026-08-18" + ) + # Not a cursor envelope: the resolved window comes back instead, so the + # caller can see what the API measured when it defaulted the range. + assert result == payload + assert result["window"] == WINDOW + + +def test_campaigns_returns_totals_and_average_rates(): + payload = {"total": 12, "active": 2, "average_open_rate": 0.41, "window": WINDOW} + rec = Recorder(json_response(200, payload)) + client = make_client(rec) + + assert client.analytics.campaigns() == payload + assert str(rec.request.url) == "http://localhost/api/v1/analytics/campaigns" + + +def test_top_campaigns_forwards_the_limit(): + payload = {"data": [{"id": "cmp_1", "open_rate": 0.62}], "window": WINDOW} + rec = Recorder(json_response(200, payload)) + client = make_client(rec) + + assert client.analytics.top_campaigns({"limit": 5}) == payload + assert str(rec.request.url) == "http://localhost/api/v1/analytics/top-campaigns?limit=5" + + +def test_analytics_has_no_iterators(): + # These endpoints answer a bounded aggregate with no cursor; an iterator over + # one would silently yield a single page. + client = make_client(Recorder(json_response(200, {}))) + assert not [name for name in dir(client.analytics) if name.startswith("iter_")] + + +def test_a_key_without_the_analytics_scope_raises_a_permission_error(): + rec = Recorder( + problem_response( + 403, + { + "type": "https://docs.sendly.now/errors/scope_missing", + "title": "Scope Missing", + "status": 403, + "code": "scope_missing", + "detail": "This API key lacks the analytics:read scope.", + }, + ) + ) + client = make_client(rec) + with pytest.raises(SendlyPermissionError) as caught: + client.analytics.timeseries() + assert caught.value.error_code == "scope_missing" diff --git a/tests/test_campaigns.py b/tests/test_campaigns.py new file mode 100644 index 0000000..82c9c8d --- /dev/null +++ b/tests/test_campaigns.py @@ -0,0 +1,262 @@ +"""Campaigns resource tests (``/api/v1``).""" + +from __future__ import annotations + +import json + +import pytest + +from sendly import SendlyConflictError, SendlyNotFoundError, SendlyRateLimitError +from support import ( + Recorder, + SequenceRecorder, + cursor_page, + json_response, + make_client, + problem_response, +) + +CAMPAIGN = { + "id": "cmp_1", + "name": "Launch", + "status": "DRAFT", + "subject": "We are live", + "audience_type": "ALL", + "stats": {"sent": 0}, +} + + +def test_list_returns_the_cursor_envelope_unwrapped_by_nobody(): + # v1 answers a bare body: has_more/next_cursor must survive to the caller. + page = cursor_page([CAMPAIGN], next_cursor="cur_2") + rec = Recorder(json_response(200, page)) + client = make_client(rec) + + result = client.campaigns.list({"limit": 1}) + + assert str(rec.request.url) == "http://localhost/api/v1/campaigns?limit=1" + assert result == page + assert result["has_more"] is True + assert result["next_cursor"] == "cur_2" + + +def test_create_posts_the_body_and_returns_the_bare_campaign(): + rec = Recorder(json_response(201, CAMPAIGN)) + client = make_client(rec) + + result = client.campaigns.create( + { + "name": "Launch", + "subject": "We are live", + "body": "

hi

", + "from": "team@sendly.now", + "audience_type": "ALL", + } + ) + + assert str(rec.request.url) == "http://localhost/api/v1/campaigns" + assert json.loads(rec.request.content)["audience_type"] == "ALL" + assert result == CAMPAIGN + + +def test_create_forwards_the_idempotency_key_header(): + rec = Recorder(json_response(201, CAMPAIGN)) + client = make_client(rec) + + client.campaigns.create({"name": "Launch"}, idempotency_key="key_123") + + assert rec.request.headers["Idempotency-Key"] == "key_123" + + +def test_create_omits_the_idempotency_header_when_no_key_is_given(): + rec = Recorder(json_response(201, CAMPAIGN)) + client = make_client(rec) + + client.campaigns.create({"name": "Launch"}) + + assert "Idempotency-Key" not in rec.request.headers + + +def test_get_and_update_and_delete_hit_the_id_path(): + rec = Recorder(json_response(200, CAMPAIGN)) + client = make_client(rec) + assert client.campaigns.get("cmp_1") == CAMPAIGN + assert str(rec.request.url) == "http://localhost/api/v1/campaigns/cmp_1" + + rec = Recorder(json_response(200, CAMPAIGN)) + client = make_client(rec) + client.campaigns.update("cmp_1", {"subject": "New subject"}) + assert rec.request.method == "PATCH" + assert json.loads(rec.request.content) == {"subject": "New subject"} + + rec = Recorder(json_response(200, {"id": "cmp_1", "deleted": True})) + client = make_client(rec) + # v1 deletes return a real body, unlike the legacy deletes that yield None. + assert client.campaigns.delete("cmp_1") == {"id": "cmp_1", "deleted": True} + assert rec.request.method == "DELETE" + + +def test_id_is_percent_encoded_into_the_path(): + rec = Recorder(json_response(200, CAMPAIGN)) + client = make_client(rec) + client.campaigns.get("cmp/../secret") + assert str(rec.request.url) == "http://localhost/api/v1/campaigns/cmp%2F..%2Fsecret" + + +def test_send_without_a_body_sends_immediately(): + rec = Recorder(json_response(200, {**CAMPAIGN, "status": "SENDING"})) + client = make_client(rec) + + result = client.campaigns.send("cmp_1") + + assert str(rec.request.url) == "http://localhost/api/v1/campaigns/cmp_1/send" + assert rec.request.method == "POST" + assert rec.request.content == b"" + assert result["status"] == "SENDING" + + +def test_send_schedules_when_given_a_scheduled_for_body_and_keys_the_replay(): + rec = Recorder(json_response(200, {**CAMPAIGN, "status": "SCHEDULED"})) + client = make_client(rec) + + client.campaigns.send( + "cmp_1", {"scheduled_for": "2026-09-01T10:00:00Z"}, idempotency_key="send_1" + ) + + assert json.loads(rec.request.content) == {"scheduled_for": "2026-09-01T10:00:00Z"} + assert rec.request.headers["Idempotency-Key"] == "send_1" + + +def test_a_replayed_send_surfaces_the_idempotency_conflict(): + rec = Recorder( + problem_response( + 409, + { + "type": "https://docs.sendly.now/errors/idempotency_key_reused", + "title": "Idempotency Key Reused", + "status": 409, + "code": "idempotency_key_reused", + "detail": "This key was used with a different request body.", + }, + ) + ) + client = make_client(rec) + with pytest.raises(SendlyConflictError) as caught: + client.campaigns.send("cmp_1", idempotency_key="send_1") + assert caught.value.error_code == "idempotency_key_reused" + + +@pytest.mark.parametrize("action", ["cancel", "pause", "resume"]) +def test_lifecycle_actions_post_to_their_own_subpath(action): + rec = Recorder(json_response(200, CAMPAIGN)) + client = make_client(rec) + + getattr(client.campaigns, action)("cmp_1") + + assert str(rec.request.url) == f"http://localhost/api/v1/campaigns/cmp_1/{action}" + assert rec.request.method == "POST" + + +def test_stats_returns_the_counter_body(): + stats = {"total_recipients": 10, "sent": 10, "opened": 4, "open_rate": 0.4} + rec = Recorder(json_response(200, stats)) + client = make_client(rec) + + assert client.campaigns.stats("cmp_1") == stats + assert str(rec.request.url) == "http://localhost/api/v1/campaigns/cmp_1/stats" + + +def test_get_raises_not_found_from_a_problem_document(): + rec = Recorder( + problem_response( + 404, + { + "type": "https://docs.sendly.now/errors/resource_not_found", + "title": "Resource Not Found", + "status": 404, + "code": "resource_not_found", + "detail": "No campaign with id cmp_missing.", + "request_id": "req_404", + }, + ) + ) + client = make_client(rec) + with pytest.raises(SendlyNotFoundError) as caught: + client.campaigns.get("cmp_missing") + assert caught.value.message == "No campaign with id cmp_missing." + assert caught.value.request_id == "req_404" + + +# --------------------------------------------------------------------------- # +# Auto-pagination # +# --------------------------------------------------------------------------- # + + +def test_iter_list_walks_every_page_and_threads_the_cursor(): + rec = SequenceRecorder( + json_response(200, cursor_page([{"id": "cmp_1"}, {"id": "cmp_2"}], next_cursor="cur_2")), + json_response(200, cursor_page([{"id": "cmp_3"}], next_cursor="cur_3")), + json_response(200, cursor_page([{"id": "cmp_4"}])), + ) + client = make_client(rec) + + ids = [campaign["id"] for campaign in client.campaigns.iter_list({"limit": 2})] + + assert ids == ["cmp_1", "cmp_2", "cmp_3", "cmp_4"] + assert rec.urls == [ + "http://localhost/api/v1/campaigns?limit=2", + "http://localhost/api/v1/campaigns?limit=2&after=cur_2", + "http://localhost/api/v1/campaigns?limit=2&after=cur_3", + ] + + +def test_iter_list_stops_on_the_last_page_without_requesting_another(): + # SequenceRecorder fails the test on an unqueued request, so a paginator that + # ignores has_more shows up here rather than looping. + rec = SequenceRecorder(json_response(200, cursor_page([{"id": "cmp_1"}]))) + client = make_client(rec) + + assert [c["id"] for c in client.campaigns.iter_list()] == ["cmp_1"] + assert len(rec.requests) == 1 + + +def test_iter_list_stops_when_has_more_is_true_but_the_cursor_is_missing(): + # A truncated page must end the walk rather than re-request page one forever. + rec = SequenceRecorder( + json_response(200, {"data": [{"id": "cmp_1"}], "has_more": True, "next_cursor": None}) + ) + client = make_client(rec) + + assert [c["id"] for c in client.campaigns.iter_list()] == ["cmp_1"] + assert len(rec.requests) == 1 + + +def test_iter_list_is_lazy_and_fetches_nothing_until_iterated(): + rec = SequenceRecorder(json_response(200, cursor_page([{"id": "cmp_1"}]))) + client = make_client(rec) + + iterator = client.campaigns.iter_list() + assert rec.requests == [] + assert next(iterator)["id"] == "cmp_1" + + +def test_iter_list_surfaces_a_mid_walk_error_instead_of_swallowing_it(): + rec = SequenceRecorder( + json_response(200, cursor_page([{"id": "cmp_1"}], next_cursor="cur_2")), + problem_response( + 429, + { + "type": "https://docs.sendly.now/errors/rate_limited", + "title": "Rate Limited", + "status": 429, + "code": "rate_limited", + }, + ), + ) + client = make_client(rec) + + iterator = client.campaigns.iter_list() + assert next(iterator)["id"] == "cmp_1" + with pytest.raises(SendlyRateLimitError) as caught: + next(iterator) + assert caught.value.error_code == "rate_limited" diff --git a/tests/test_contract.py b/tests/test_contract.py index 10c7ac7..86fef8f 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -93,6 +93,33 @@ def _resolve_ref(spec: dict[str, Any], node: Any) -> Any: return node +#: Members that identify the ``/api/v1`` cursor-list envelope. No ``total`` -- +#: the API deliberately does not count a project's rows on every page. +CURSOR_ENVELOPE = frozenset({"data", "has_more", "next_cursor"}) + + +def _cursor_list_operations(spec: dict[str, Any]) -> set[tuple[str, str]]: + """Spec operations answering the cursor envelope, as ``(VERB, normalized_path)``. + + These -- and only these -- are the operations an ``iter_*`` companion can + walk. The other v1 listings (analytics, event names/stats) return a bounded + aggregate with no cursor. + """ + cursor_ops: set[tuple[str, str]] = set() + for verb, path, norm in _spec_operations(spec): + operation = spec["paths"][path][verb.lower()] + for code, response in operation.get("responses", {}).items(): + if not code.startswith("2"): + continue + resolved = _resolve_ref(spec, response) + for media in resolved.get("content", {}).values(): + schema = _resolve_ref(spec, media.get("schema", {})) + properties = schema.get("properties", {}) if isinstance(schema, dict) else {} + if CURSOR_ENVELOPE.issubset(properties): + cursor_ops.add((verb, norm)) + return cursor_ops + + def _required_fields(spec: dict[str, Any], path: str, method: str) -> set[str]: operation = spec["paths"][path][method] schema = _resolve_ref(spec, operation["requestBody"]["content"]["application/json"]["schema"]) @@ -186,9 +213,12 @@ def _sdk_operations() -> list[tuple[str, str, str]]: def test_introspection_is_not_vacuous(): # A broken fixture or extractor would make the coverage checks pass trivially. + # The floor tracks the real surface (legacy /api/* plus /api/v1/*) with enough + # slack that a single retired endpoint does not fail the suite here -- the + # coverage tests below are what catch drift. spec = _load_spec() - assert len(_spec_operations(spec)) >= 30 - assert len(_sdk_operations()) >= 30 + assert len(_spec_operations(spec)) >= 60 + assert len(_sdk_operations()) >= 60 def test_every_spec_operation_is_implemented_or_listed(): @@ -237,6 +267,71 @@ def test_every_sdk_method_matches_a_spec_operation(): ) +def test_every_cursor_paginated_list_has_an_iterator(): + spec = _load_spec() + cursor_ops = _cursor_list_operations(spec) + # campaigns, segments, segment contacts, workflows, workflow executions, events. + assert len(cursor_ops) >= 6, "cursor-envelope detection found nothing -- spec shape changed?" + + resources = _discover_resources() + missing = sorted( + f"{label} (add {label.split('.', 1)[0]}.iter_{label.split('.', 1)[1]})" + for verb, norm, label in _sdk_operations() + if (verb, norm) in cursor_ops + and not hasattr(resources[label.split(".", 1)[0]], f"iter_{label.split('.', 1)[1]}") + ) + assert not missing, ( + "Cursor-paginated list methods with no auto-pagination companion:\n " + + "\n ".join(missing) + ) + + +def test_every_iterator_wraps_a_cursor_paginated_list(): + # The reverse guard: an iterator over a response that carries no cursor would + # silently yield one page and stop. + spec = _load_spec() + cursor_ops = _cursor_list_operations(spec) + cursor_labels = {label for verb, norm, label in _sdk_operations() if (verb, norm) in cursor_ops} + + stray = sorted( + f"{attr}.{name}" + for attr, klass in _discover_resources().items() + for name, _func in inspect.getmembers(klass, predicate=inspect.isfunction) + if name.startswith("iter_") and f"{attr}.{name[len('iter_') :]}" not in cursor_labels + ) + assert not stray, ( + "Iterator methods whose underlying list operation is not cursor-paginated " + "in the spec:\n " + "\n ".join(stray) + ) + + +def test_v1_methods_never_unwrap_an_envelope(): + # /api/v1 returns the resource body directly. Calling unwrap() on a v1 list + # would strip has_more/next_cursor and hand back the data array alone. + offenders: list[str] = [] + for attr, klass in sorted(_discover_resources().items()): + for name, func in inspect.getmembers(klass, predicate=inspect.isfunction): + if name.startswith("_"): + continue + tree = ast.parse(textwrap.dedent(inspect.getsource(func))) + calls = _request_calls(tree) + paths = [_verb_and_path(call, f"{klass.__name__}.{name}")[1] for call in calls] + if not any(path.startswith("/api/v1") for path in paths): + continue + unwraps = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "unwrap" + ] + if unwraps: + offenders.append(f"{attr}.{name}") + assert not offenders, ( + "v1 methods calling unwrap(); v1 responses are not enveloped:\n " + "\n ".join(offenders) + ) + + def test_core_operations_forward_a_body_and_match_required_fields(): spec = _load_spec() resources = _discover_resources() diff --git a/tests/test_errors_problem.py b/tests/test_errors_problem.py new file mode 100644 index 0000000..b969ba7 --- /dev/null +++ b/tests/test_errors_problem.py @@ -0,0 +1,198 @@ +"""RFC 9457 problem+json error mapping, and the legacy dialect it must not disturb. + +The ``/api/v1`` surface reports failures as problem documents while the legacy +``/api/*`` surface keeps its ``{success, error}`` envelope. Both dialects land on +the same exception classes, keyed off the HTTP status, so a caller's +``except SendlyValidationError`` works against either. +""" + +from __future__ import annotations + +import pytest + +from sendly import ( + SendlyAuthenticationError, + SendlyConflictError, + SendlyError, + SendlyNotFoundError, + SendlyPermissionError, + SendlyRateLimitError, + SendlyServerError, + SendlyValidationError, +) +from support import Recorder, json_response, make_client, problem_response + + +def problem(status: int, code: str, **extra: object) -> dict[str, object]: + """A minimally complete problem document for ``status`` / ``code``.""" + return { + "type": f"https://docs.sendly.now/errors/{code}", + "title": code.replace("_", " ").title(), + "status": status, + "code": code, + **extra, + } + + +# --------------------------------------------------------------------------- # +# Status -> exception class # +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + ("status", "code", "expected"), + [ + (401, "invalid_api_key", SendlyAuthenticationError), + (401, "invalid_session", SendlyAuthenticationError), + (403, "scope_missing", SendlyPermissionError), + (403, "project_access_denied", SendlyPermissionError), + (403, "project_disabled", SendlyPermissionError), + (404, "resource_not_found", SendlyNotFoundError), + (409, "conflict", SendlyConflictError), + (409, "idempotency_key_reused", SendlyConflictError), + (422, "validation_error", SendlyValidationError), + (429, "rate_limited", SendlyRateLimitError), + (429, "quota_exhausted", SendlyRateLimitError), + (500, "internal_error", SendlyServerError), + (500, "enqueue_failed", SendlyServerError), + ], +) +def test_problem_document_maps_to_the_class_for_its_status(status, code, expected): + client = make_client(Recorder(problem_response(status, problem(status, code)))) + with pytest.raises(expected) as caught: + client.campaigns.list() + assert caught.value.status_code == status + assert caught.value.error_code == code + + +def test_error_code_comes_from_the_problem_code_not_the_type_uri(): + client = make_client(Recorder(problem_response(403, problem(403, "scope_missing")))) + with pytest.raises(SendlyPermissionError) as caught: + client.campaigns.list() + assert caught.value.error_code == "scope_missing" + assert caught.value.body["type"] == "https://docs.sendly.now/errors/scope_missing" + + +# --------------------------------------------------------------------------- # +# Message, request_id, field errors # +# --------------------------------------------------------------------------- # + + +def test_message_prefers_detail_over_title(): + document = problem(422, "validation_error", detail="`limit` must be between 1 and 100.") + client = make_client(Recorder(problem_response(422, document))) + with pytest.raises(SendlyValidationError) as caught: + client.campaigns.list({"limit": 500}) + assert caught.value.message == "`limit` must be between 1 and 100." + + +def test_message_falls_back_to_title_when_detail_is_absent(): + client = make_client(Recorder(problem_response(500, problem(500, "internal_error")))) + with pytest.raises(SendlyServerError) as caught: + client.campaigns.list() + assert caught.value.message == "Internal Error" + + +def test_request_id_and_field_errors_are_exposed_on_the_error(): + document = problem( + 422, + "validation_error", + detail="The request body did not match the schema.", + request_id="req_01HZY", + instance="/api/v1/campaigns", + errors=[ + {"pointer": "/subject", "code": "required", "message": "subject is required"}, + {"pointer": "/from", "code": "invalid_email", "message": "from is not an email"}, + ], + ) + client = make_client(Recorder(problem_response(422, document))) + with pytest.raises(SendlyValidationError) as caught: + client.campaigns.create({"name": "Launch"}) + + error = caught.value + assert error.request_id == "req_01HZY" + assert error.field_errors is not None + assert [item["pointer"] for item in error.field_errors] == ["/subject", "/from"] + assert error.field_errors[0]["code"] == "required" + # The whole document stays reachable for members the SDK does not promote. + assert error.body["instance"] == "/api/v1/campaigns" + + +def test_request_id_and_field_errors_are_none_when_the_problem_omits_them(): + client = make_client(Recorder(problem_response(404, problem(404, "resource_not_found")))) + with pytest.raises(SendlyNotFoundError) as caught: + client.campaigns.get("cmp_missing") + assert caught.value.request_id is None + assert caught.value.field_errors is None + + +# --------------------------------------------------------------------------- # +# Detection # +# --------------------------------------------------------------------------- # + + +def test_problem_is_detected_by_shape_when_the_content_type_is_rewritten(): + # A proxy that normalizes the media type must not downgrade a v1 error into + # the generic http_ path. + document = problem(429, "rate_limited", detail="Slow down.") + client = make_client(Recorder(json_response(429, document))) + with pytest.raises(SendlyRateLimitError) as caught: + client.campaigns.list() + assert caught.value.error_code == "rate_limited" + assert caught.value.message == "Slow down." + + +def test_problem_content_type_wins_even_when_members_are_missing(): + client = make_client(Recorder(problem_response(503, {"status": 503}))) + with pytest.raises(SendlyServerError) as caught: + client.usage.get() + assert caught.value.error_code == "http_503" + assert caught.value.message == "Sendly request failed with status 503" + + +# --------------------------------------------------------------------------- # +# Legacy dialect regression # +# --------------------------------------------------------------------------- # + + +def test_legacy_envelope_still_maps_to_the_same_classes(): + rec = Recorder( + json_response( + 422, + { + "success": False, + "error": { + "message": "Invalid email", + "code": "VALIDATION_ERROR", + "details": {"errors": [{"path": "email"}]}, + }, + }, + ) + ) + client = make_client(rec) + with pytest.raises(SendlyValidationError) as caught: + client.contacts.create({"email": "nope"}) + + error = caught.value + assert error.error_code == "VALIDATION_ERROR" + assert error.message == "Invalid email" + # The legacy breakdown stays where it always was; the problem-only fields + # are simply absent. + assert error.body["error"]["details"]["errors"] == [{"path": "email"}] + assert error.request_id is None + assert error.field_errors is None + + +def test_legacy_envelope_is_never_mistaken_for_a_problem_document(): + rec = Recorder(json_response(500, {"success": False, "error": {"message": "boom"}})) + client = make_client(rec) + with pytest.raises(SendlyServerError) as caught: + client.emails.list() + assert caught.value.error_code == "http_500" + assert caught.value.message == "boom" + + +def test_transport_and_option_errors_carry_the_new_fields_as_none(): + error = SendlyError(0, "invalid_options", "no key") + assert error.request_id is None + assert error.field_errors is None diff --git a/tests/test_events.py b/tests/test_events.py index 871764f..e0ca693 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,4 +1,4 @@ -"""Events resource tests.""" +"""Events resource tests — legacy ``/api/track`` and the ``/api/v1/events`` surface.""" from __future__ import annotations @@ -7,7 +7,14 @@ import pytest from sendly import SendlyValidationError -from support import Recorder, json_response, make_client +from support import ( + Recorder, + SequenceRecorder, + cursor_page, + json_response, + make_client, + problem_response, +) def test_track_posts_track_with_bearer_and_body(): @@ -61,3 +68,125 @@ def test_track_raises_validation_error_on_400(): client = make_client(rec) with pytest.raises(SendlyValidationError): client.events.track({"event": "email.sent", "email": "a@b.com"}) + + +# --------------------------------------------------------------------------- # +# /api/v1/events # +# --------------------------------------------------------------------------- # + +EVENT = { + "id": "evt_1", + "name": "signup.completed", + "contact_id": "con_1", + "data": {"plan": "pro"}, + "created_at": "2026-08-18T10:00:00.000Z", +} + + +def test_record_posts_to_v1_and_returns_the_bare_event(): + rec = Recorder(json_response(201, EVENT)) + client = make_client(rec) + + result = client.events.record({"name": "signup.completed", "contact_id": "con_1"}) + + assert str(rec.request.url) == "http://localhost/api/v1/events" + assert rec.request.method == "POST" + assert json.loads(rec.request.content)["name"] == "signup.completed" + # v1 is not enveloped: the event body arrives as-is, no `data` unwrap. + assert result == EVENT + + +def test_record_sends_no_idempotency_key(): + # Events are the highest-volume write on the surface and append-only, so the + # API deliberately does not ledger them. `record` therefore takes no key. + rec = Recorder(json_response(201, EVENT)) + client = make_client(rec) + client.events.record({"name": "signup.completed"}) + assert "Idempotency-Key" not in rec.request.headers + + +def test_track_and_record_stay_separate_surfaces(): + # The legacy method keeps its own path and its own envelope handling. + rec = Recorder(json_response(200, {"success": True, "data": {"event": "e"}})) + client = make_client(rec) + client.events.track({"event": "signup", "email": "a@b.com"}) + assert str(rec.request.url) == "http://localhost/api/track" + + +def test_list_forwards_the_event_name_filter_and_returns_the_cursor_envelope(): + page = cursor_page([EVENT], next_cursor="cur_2") + rec = Recorder(json_response(200, page)) + client = make_client(rec) + + result = client.events.list({"event_name": "signup.completed", "limit": 20}) + + url = str(rec.request.url) + assert url.startswith("http://localhost/api/v1/events?") + assert "event_name=signup.completed" in url + assert "limit=20" in url + assert result == page + + +def test_iter_list_walks_every_page_and_preserves_the_filter(): + rec = SequenceRecorder( + json_response(200, cursor_page([{"id": "evt_1"}], next_cursor="cur_2")), + json_response(200, cursor_page([{"id": "evt_2"}])), + ) + client = make_client(rec) + + ids = [e["id"] for e in client.events.iter_list({"event_name": "signup.completed"})] + + assert ids == ["evt_1", "evt_2"] + assert rec.urls == [ + "http://localhost/api/v1/events?event_name=signup.completed", + "http://localhost/api/v1/events?event_name=signup.completed&after=cur_2", + ] + + +def test_list_names_returns_the_distinct_names(): + rec = Recorder(json_response(200, {"data": ["signup.completed", "purchase.made"]})) + client = make_client(rec) + + result = client.events.list_names() + + assert str(rec.request.url) == "http://localhost/api/v1/events/names" + assert result["data"] == ["signup.completed", "purchase.made"] + + +def test_stats_forwards_the_window(): + payload = { + "data": [{"name": "signup.completed", "count": 42}], + "window": {"from": "2026-08-01"}, + } + rec = Recorder(json_response(200, payload)) + client = make_client(rec) + + assert client.events.stats({"from": "2026-08-01"}) == payload + assert str(rec.request.url) == "http://localhost/api/v1/events/stats?from=2026-08-01" + + +def test_record_raises_validation_error_from_a_problem_document(): + rec = Recorder( + problem_response( + 422, + { + "type": "https://docs.sendly.now/errors/validation_error", + "title": "Validation Error", + "status": 422, + "code": "validation_error", + "detail": "`name` is a reserved system event name.", + "request_id": "req_evt", + "errors": [ + {"pointer": "/name", "code": "reserved", "message": "reserved event name"} + ], + }, + ) + ) + client = make_client(rec) + + with pytest.raises(SendlyValidationError) as caught: + client.events.record({"name": "email.sent"}) + + assert caught.value.error_code == "validation_error" + assert caught.value.request_id == "req_evt" + assert caught.value.field_errors[0]["pointer"] == "/name" diff --git a/tests/test_lists.py b/tests/test_lists.py new file mode 100644 index 0000000..5fc7c95 --- /dev/null +++ b/tests/test_lists.py @@ -0,0 +1,123 @@ +"""Lists resource tests (legacy ``/api/lists`` subscribe / unsubscribe).""" + +from __future__ import annotations + +import json + +import pytest + +from sendly import SendlyConflictError +from support import Recorder, json_response, make_client + + +def test_subscribe_posts_the_email_and_unwraps_the_membership(): + rec = Recorder( + json_response( + 200, + { + "success": True, + "data": { + "membershipId": "mem_1", + "status": "CONFIRMED", + "created": True, + "previousStatus": None, + }, + }, + ) + ) + client = make_client(rec) + + result = client.lists.subscribe("lst_1", {"email": "a@b.com"}) + + assert str(rec.request.url) == "http://localhost/api/lists/lst_1/subscribe" + assert rec.request.method == "POST" + assert json.loads(rec.request.content) == {"email": "a@b.com"} + # Legacy dialect: the {success, data} envelope is unwrapped for the caller. + assert result["membershipId"] == "mem_1" + assert result["previousStatus"] is None + + +def test_subscribe_to_a_double_opt_in_list_returns_pending_and_a_confirm_token(): + # Sendly does not send the confirmation email -- the caller must deliver the + # token, so it has to reach them intact. + rec = Recorder( + json_response( + 200, + { + "success": True, + "data": { + "membershipId": "mem_2", + "status": "PENDING", + "created": True, + "previousStatus": None, + "confirmToken": "tok_abc", + }, + }, + ) + ) + client = make_client(rec) + + result = client.lists.subscribe("lst_1", {"email": "a@b.com"}) + + assert result["status"] == "PENDING" + assert result["confirmToken"] == "tok_abc" + + +def test_resubscribing_an_opted_out_address_conflicts_without_allow_resubscribe(): + rec = Recorder( + json_response( + 409, + { + "success": False, + "error": { + "message": "This address previously unsubscribed.", + "code": "RESUBSCRIBE_CONFIRMATION_REQUIRED", + }, + }, + ) + ) + client = make_client(rec) + + with pytest.raises(SendlyConflictError) as caught: + client.lists.subscribe("lst_1", {"email": "a@b.com"}) + assert caught.value.error_code == "RESUBSCRIBE_CONFIRMATION_REQUIRED" + + +def test_allow_resubscribe_is_forwarded_in_the_body(): + rec = Recorder( + json_response( + 200, + { + "success": True, + "data": { + "membershipId": "mem_1", + "status": "CONFIRMED", + "created": False, + "previousStatus": "UNSUBSCRIBED", + }, + }, + ) + ) + client = make_client(rec) + + result = client.lists.subscribe("lst_1", {"email": "a@b.com", "allowResubscribe": True}) + + assert json.loads(rec.request.content)["allowResubscribe"] is True + assert result["previousStatus"] == "UNSUBSCRIBED" + + +def test_unsubscribe_posts_to_the_unsubscribe_path_and_echoes_the_address(): + rec = Recorder(json_response(200, {"success": True, "data": {"email": "a@b.com"}})) + client = make_client(rec) + + result = client.lists.unsubscribe("lst_1", {"email": "a@b.com"}) + + assert str(rec.request.url) == "http://localhost/api/lists/lst_1/unsubscribe" + assert result == {"email": "a@b.com"} + + +def test_list_id_is_percent_encoded_into_the_path(): + rec = Recorder(json_response(200, {"success": True, "data": {"email": "a@b.com"}})) + client = make_client(rec) + client.lists.unsubscribe("lst/1", {"email": "a@b.com"}) + assert str(rec.request.url) == "http://localhost/api/lists/lst%2F1/unsubscribe" diff --git a/tests/test_segments.py b/tests/test_segments.py new file mode 100644 index 0000000..6e65e96 --- /dev/null +++ b/tests/test_segments.py @@ -0,0 +1,125 @@ +"""Segments resource tests (``/api/v1``).""" + +from __future__ import annotations + +import json + +import pytest + +from sendly import SendlyValidationError +from support import ( + Recorder, + SequenceRecorder, + cursor_page, + json_response, + make_client, + problem_response, +) + +SEGMENT = { + "id": "seg_1", + "name": "Power users", + "type": "DYNAMIC", + "condition": {"field": "plan", "op": "eq", "value": "pro"}, + "member_count": 42, +} + + +def test_list_returns_the_cursor_envelope(): + page = cursor_page([SEGMENT]) + rec = Recorder(json_response(200, page)) + client = make_client(rec) + + assert client.segments.list() == page + assert str(rec.request.url) == "http://localhost/api/v1/segments" + + +def test_create_posts_the_body_and_sends_no_idempotency_key(): + # Creating a segment neither sends mail nor consumes quota, so the API + # deliberately does not ledger it. + rec = Recorder(json_response(201, SEGMENT)) + client = make_client(rec) + + result = client.segments.create({"name": "Power users", "type": "DYNAMIC"}) + + assert str(rec.request.url) == "http://localhost/api/v1/segments" + assert json.loads(rec.request.content)["name"] == "Power users" + assert "Idempotency-Key" not in rec.request.headers + assert result == SEGMENT + + +def test_get_update_and_delete_hit_the_id_path(): + rec = Recorder(json_response(200, SEGMENT)) + client = make_client(rec) + assert client.segments.get("seg_1") == SEGMENT + assert str(rec.request.url) == "http://localhost/api/v1/segments/seg_1" + + rec = Recorder(json_response(200, SEGMENT)) + client = make_client(rec) + client.segments.update("seg_1", {"name": "Renamed"}) + assert rec.request.method == "PATCH" + assert json.loads(rec.request.content) == {"name": "Renamed"} + + rec = Recorder(json_response(200, {"id": "seg_1", "deleted": True})) + client = make_client(rec) + assert client.segments.delete("seg_1") == {"id": "seg_1", "deleted": True} + assert rec.request.method == "DELETE" + + +def test_an_invalid_dynamic_condition_fails_the_create_with_field_errors(): + rec = Recorder( + problem_response( + 422, + { + "type": "https://docs.sendly.now/errors/validation_error", + "title": "Validation Error", + "status": 422, + "code": "validation_error", + "detail": "`condition` is not a valid segment condition.", + "errors": [ + {"pointer": "/condition/op", "code": "invalid_enum", "message": "unknown op"} + ], + }, + ) + ) + client = make_client(rec) + + with pytest.raises(SendlyValidationError) as caught: + client.segments.create({"name": "Broken", "type": "DYNAMIC", "condition": {"op": "??"}}) + assert caught.value.field_errors[0]["pointer"] == "/condition/op" + + +def test_list_contacts_hits_the_nested_path_with_pagination_params(): + page = cursor_page([{"id": "con_1", "email": "a@b.com"}]) + rec = Recorder(json_response(200, page)) + client = make_client(rec) + + assert client.segments.list_contacts("seg_1", {"limit": 50}) == page + assert str(rec.request.url) == "http://localhost/api/v1/segments/seg_1/contacts?limit=50" + + +def test_iter_list_walks_every_page(): + rec = SequenceRecorder( + json_response(200, cursor_page([{"id": "seg_1"}], next_cursor="cur_2")), + json_response(200, cursor_page([{"id": "seg_2"}])), + ) + client = make_client(rec) + + assert [s["id"] for s in client.segments.iter_list()] == ["seg_1", "seg_2"] + assert rec.urls[1] == "http://localhost/api/v1/segments?after=cur_2" + + +def test_iter_list_contacts_keeps_the_segment_id_and_filters_across_pages(): + rec = SequenceRecorder( + json_response(200, cursor_page([{"id": "con_1"}], next_cursor="cur_2")), + json_response(200, cursor_page([{"id": "con_2"}])), + ) + client = make_client(rec) + + ids = [c["id"] for c in client.segments.iter_list_contacts("seg_1", {"limit": 1})] + + assert ids == ["con_1", "con_2"] + assert rec.urls == [ + "http://localhost/api/v1/segments/seg_1/contacts?limit=1", + "http://localhost/api/v1/segments/seg_1/contacts?limit=1&after=cur_2", + ] diff --git a/tests/test_usage.py b/tests/test_usage.py new file mode 100644 index 0000000..ac5de6f --- /dev/null +++ b/tests/test_usage.py @@ -0,0 +1,56 @@ +"""Usage resource tests (``/api/v1``).""" + +from __future__ import annotations + +import pytest + +from sendly import SendlyRateLimitError +from support import Recorder, json_response, make_client, problem_response + +USAGE = { + "plan": {"name": "pro", "monthly_email_limit": 100_000}, + "monthly": {"emails_sent": 12_500, "remaining": 87_500}, + "daily": {"emails_sent": 900}, +} + + +def test_get_requests_the_usage_path_and_returns_the_bare_body(): + rec = Recorder(json_response(200, USAGE)) + client = make_client(rec) + + result = client.usage.get() + + assert str(rec.request.url) == "http://localhost/api/v1/usage" + assert rec.request.method == "GET" + assert result == USAGE + assert result["monthly"]["remaining"] == 87_500 + + +def test_get_sends_no_body_and_no_query(): + rec = Recorder(json_response(200, USAGE)) + client = make_client(rec) + client.usage.get() + assert rec.request.content == b"" + assert rec.request.url.query == b"" + + +def test_quota_exhausted_is_a_rate_limit_error_distinguished_by_its_code(): + # 429 quota_exhausted is not fixed by backing off, unlike 429 rate_limited. + rec = Recorder( + problem_response( + 429, + { + "type": "https://docs.sendly.now/errors/quota_exhausted", + "title": "Quota Exhausted", + "status": 429, + "code": "quota_exhausted", + "detail": "Monthly email quota reached for this plan.", + }, + ) + ) + client = make_client(rec) + + with pytest.raises(SendlyRateLimitError) as caught: + client.usage.get() + assert caught.value.error_code == "quota_exhausted" + assert caught.value.message == "Monthly email quota reached for this plan." diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..191ce6b --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,159 @@ +"""Workflows resource tests (``/api/v1``).""" + +from __future__ import annotations + +import json + +import pytest + +from sendly import SendlyNotFoundError +from support import ( + Recorder, + SequenceRecorder, + cursor_page, + json_response, + make_client, + problem_response, +) + +WORKFLOW = { + "id": "wf_1", + "name": "Welcome series", + "enabled": True, + "trigger_type": "EVENT", + "event_name": "signup.completed", + "version": 3, +} + +EXECUTION = { + "id": "exe_1", + "workflow_id": "wf_1", + "contact_id": "con_1", + "status": "RUNNING", +} + + +def test_list_and_create_hit_the_collection_path(): + page = cursor_page([WORKFLOW]) + rec = Recorder(json_response(200, page)) + client = make_client(rec) + assert client.workflows.list() == page + assert str(rec.request.url) == "http://localhost/api/v1/workflows" + + rec = Recorder(json_response(201, WORKFLOW)) + client = make_client(rec) + result = client.workflows.create({"name": "Welcome series", "event_name": "signup.completed"}) + assert rec.request.method == "POST" + assert json.loads(rec.request.content)["event_name"] == "signup.completed" + assert result == WORKFLOW + + +def test_get_update_and_delete_hit_the_id_path(): + rec = Recorder(json_response(200, WORKFLOW)) + client = make_client(rec) + assert client.workflows.get("wf_1") == WORKFLOW + assert str(rec.request.url) == "http://localhost/api/v1/workflows/wf_1" + + rec = Recorder(json_response(200, {**WORKFLOW, "enabled": False})) + client = make_client(rec) + # Disabling is how a workflow is paused -- there is no separate action. + assert client.workflows.update("wf_1", {"enabled": False})["enabled"] is False + assert rec.request.method == "PATCH" + + rec = Recorder(json_response(200, {"id": "wf_1", "deleted": True})) + client = make_client(rec) + assert client.workflows.delete("wf_1") == {"id": "wf_1", "deleted": True} + + +def test_list_executions_forwards_the_status_filter(): + page = cursor_page([EXECUTION]) + rec = Recorder(json_response(200, page)) + client = make_client(rec) + + assert client.workflows.list_executions("wf_1", {"status": "RUNNING"}) == page + assert ( + str(rec.request.url) == "http://localhost/api/v1/workflows/wf_1/executions?status=RUNNING" + ) + + +def test_start_execution_posts_the_contact_to_the_nested_path(): + rec = Recorder(json_response(201, EXECUTION)) + client = make_client(rec) + + result = client.workflows.start_execution("wf_1", {"contact_id": "con_1", "context": {"a": 1}}) + + assert str(rec.request.url) == "http://localhost/api/v1/workflows/wf_1/executions" + assert rec.request.method == "POST" + assert json.loads(rec.request.content) == {"contact_id": "con_1", "context": {"a": 1}} + assert result == EXECUTION + + +def test_cancel_execution_is_addressed_by_execution_id_alone(): + # The route is /api/v1/workflows/executions/{execution_id}/cancel -- NOT + # nested under the workflow id. + rec = Recorder(json_response(200, {**EXECUTION, "status": "CANCELLED"})) + client = make_client(rec) + + result = client.workflows.cancel_execution("exe_1") + + assert str(rec.request.url) == "http://localhost/api/v1/workflows/executions/exe_1/cancel" + assert rec.request.method == "POST" + assert result["status"] == "CANCELLED" + + +def test_cancel_execution_percent_encodes_the_execution_id(): + rec = Recorder(json_response(200, EXECUTION)) + client = make_client(rec) + client.workflows.cancel_execution("exe/1") + assert str(rec.request.url) == "http://localhost/api/v1/workflows/executions/exe%2F1/cancel" + + +def test_stats_forwards_the_window_filter(): + stats = {"workflow_id": "wf_1", "total": 120, "completion_rate": 0.85} + rec = Recorder(json_response(200, stats)) + client = make_client(rec) + + assert client.workflows.stats("wf_1", {"from": "2026-08-01"}) == stats + assert str(rec.request.url) == "http://localhost/api/v1/workflows/wf_1/stats?from=2026-08-01" + + +def test_cancel_execution_raises_not_found_from_a_problem_document(): + rec = Recorder( + problem_response( + 404, + { + "type": "https://docs.sendly.now/errors/resource_not_found", + "title": "Resource Not Found", + "status": 404, + "code": "resource_not_found", + }, + ) + ) + client = make_client(rec) + with pytest.raises(SendlyNotFoundError): + client.workflows.cancel_execution("exe_missing") + + +def test_iter_list_walks_every_page(): + rec = SequenceRecorder( + json_response(200, cursor_page([{"id": "wf_1"}], next_cursor="cur_2")), + json_response(200, cursor_page([{"id": "wf_2"}])), + ) + client = make_client(rec) + assert [w["id"] for w in client.workflows.iter_list()] == ["wf_1", "wf_2"] + + +def test_iter_list_executions_keeps_the_workflow_id_and_filter_across_pages(): + rec = SequenceRecorder( + json_response(200, cursor_page([{"id": "exe_1"}], next_cursor="cur_2")), + json_response(200, cursor_page([{"id": "exe_2"}])), + ) + client = make_client(rec) + + ids = [e["id"] for e in client.workflows.iter_list_executions("wf_1", {"status": "RUNNING"})] + + assert ids == ["exe_1", "exe_2"] + assert rec.urls == [ + "http://localhost/api/v1/workflows/wf_1/executions?status=RUNNING", + "http://localhost/api/v1/workflows/wf_1/executions?status=RUNNING&after=cur_2", + ] From aa494b45d7ab02b34e3ac6639c5965cd03393ee5 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:15:52 +0100 Subject: [PATCH 2/4] docs: document the v1 surface, auto-pagination and the RFC 9457 error fields --- CHANGELOG.md | 44 +++++++++++++ README.md | 153 +++++++++++++++++++++++++++++++++++++++++++ src/sendly/client.py | 5 +- 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b961242..da125f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index a9f7b7f..52b87d3 100644 --- a/README.md +++ b/README.md @@ -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": "

Hello

", + "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`, @@ -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 diff --git a/src/sendly/client.py b/src/sendly/client.py index 88ea8c4..1f40e2d 100644 --- a/src/sendly/client.py +++ b/src/sendly/client.py @@ -79,9 +79,8 @@ class Sendly: 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``. + surface, plus ``campaigns``, ``segments``, ``workflows``, ``analytics`` and + ``usage`` on ``/api/v1``. Args: api_key: Project API key (``sk_*`` for full access, ``pk_*`` for From b443f12f36031f55120a2082001ef9e1cb8b4f9c Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:15:58 +0100 Subject: [PATCH 3/4] chore(release): bump sendly-python to 0.2.0 --- pyproject.toml | 2 +- src/sendly/client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d40166c..ac00d08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/sendly/client.py b/src/sendly/client.py index 1f40e2d..b370873 100644 --- a/src/sendly/client.py +++ b/src/sendly/client.py @@ -57,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" From 6aaf30a27b5329ee37b70a0bcc34d96a5ca9ca46 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:22:04 +0100 Subject: [PATCH 4/4] fix(v1): require the start_execution body and drop the unused list_names query --- src/sendly/resources/events.py | 12 +++++----- src/sendly/resources/workflows.py | 5 ++-- tests/test_contract.py | 39 +++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/sendly/resources/events.py b/src/sendly/resources/events.py index 86f6f57..18a3f40 100644 --- a/src/sendly/resources/events.py +++ b/src/sendly/resources/events.py @@ -79,15 +79,15 @@ def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]: """Iterate every matching event across pages, following the cursor for you.""" return iterate_cursor(self.list, query) - def list_names(self, query: Query | None = None) -> EventNameList: + def list_names(self) -> EventNameList: """The distinct event names recorded on the project. - Useful for building a workflow trigger: a workflow's ``event_name`` has - to match a name events are actually recorded under. + Takes no arguments — the endpoint declares no parameters, and the answer + is the project's whole name set. Useful for building a workflow trigger: + a workflow's ``event_name`` has to match a name events are actually + recorded under. """ - response: EventNameList = self._client.request( - method="GET", path="/api/v1/events/names", query=query - ) + response: EventNameList = self._client.request(method="GET", path="/api/v1/events/names") return response def stats(self, query: Query | None = None) -> EventStats: diff --git a/src/sendly/resources/workflows.py b/src/sendly/resources/workflows.py index 2e0cc93..d78f017 100644 --- a/src/sendly/resources/workflows.py +++ b/src/sendly/resources/workflows.py @@ -99,10 +99,11 @@ def iter_list_executions(self, id: str, query: Query | None = None) -> Iterator[ """Iterate every execution of a workflow across pages.""" return iterate_cursor(lambda params: self.list_executions(id, params), query) - def start_execution(self, id: str, body: Body | None = None) -> WorkflowExecutionRecord: + def start_execution(self, id: str, body: Body) -> WorkflowExecutionRecord: """Start the workflow for one contact, bypassing its event trigger. - ``body`` requires ``contact_id`` and may carry a ``context`` object the + ``body`` is required and must carry ``contact_id`` — an execution always + belongs to a contact. It may also carry a ``context`` object the workflow's steps can read. """ response: WorkflowExecutionRecord = self._client.request( diff --git a/tests/test_contract.py b/tests/test_contract.py index 86fef8f..849da04 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -348,3 +348,42 @@ def test_core_operations_forward_a_body_and_match_required_fields(): assert "email" in _required_fields(spec, "/api/contacts", "post"), ( "createContact no longer requires 'email' in the spec -- revisit contacts.create." ) + + +def test_a_required_request_body_is_a_required_python_argument(): + # A body the spec marks required must not be defaulted to None, or a caller + # can send an empty request the API is guaranteed to reject. + spec = _load_spec() + resources = _discover_resources() + checks = [ + ("workflows", "start_execution", "/api/v1/workflows/{id}/executions", "contact_id"), + ("segments", "create", "/api/v1/segments", "name"), + ("events", "record", "/api/v1/events", "name"), + ] + for attr, method, path, field in checks: + operation = spec["paths"][path]["post"] + assert operation["requestBody"].get("required") is True, ( + f"{path} no longer requires a request body -- revisit {attr}.{method}." + ) + assert field in _required_fields(spec, path, "post"), ( + f"{path} no longer requires '{field}' -- revisit {attr}.{method}." + ) + parameter = inspect.signature(getattr(resources[attr], method)).parameters["body"] + assert parameter.default is inspect.Parameter.empty, ( + f"{attr}.{method} defaults its body, but the spec requires one." + ) + + +def test_a_parameterless_operation_takes_no_query_argument(): + # The spec declares no parameters on these, so a query argument would invite + # callers to send filters the API silently ignores. + spec = _load_spec() + resources = _discover_resources() + for attr, method, verb, path in [("events", "list_names", "get", "/api/v1/events/names")]: + assert not spec["paths"][path][verb].get("parameters"), ( + f"{verb.upper()} {path} now declares parameters -- revisit {attr}.{method}." + ) + parameters = inspect.signature(getattr(resources[attr], method)).parameters + assert list(parameters) == ["self"], ( + f"{attr}.{method} accepts {list(parameters)[1:]}, but the spec declares no parameters." + )