diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24b14b7..2d516f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,327 @@
All notable changes to `sendly-python` are documented here. This project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.1.0] - 2026-09-05
+
+Four new resources, the `/api/v1` half of six that only had a legacy one, and a
+set of renames the platform made on the wire. Most of this release is additive,
+but the renames are breaking, so it is a major-in-spirit minor: 1.1 talks to an
+API that 1.0 did not.
+
+> **The platform deploy this release waited on has shipped** — monorepo commit
+> `57826bad`, deployed 2026-09-06 — so 1.1 is releasable. Anyone still running
+> the pre-`57826bad` platform should stay on 1.0: an SDK sending `emailCategory`
+> at an API that still expects `type` is answered `422 validation_error` on every
+> template and campaign write.
+>
+> The vendored `tests/fixtures/openapi.json` is that released contract byte for
+> byte, taken from the monorepo at `57826bad` rather than synced from the
+> deployed API — see `scripts/sync_spec.py` for why production is never the
+> source.
+
+### Breaking
+
+- **`Template.type` and `Campaign.type` are now `emailCategory`** on the legacy
+ dialect and `email_category` on v1. It affects `templates.create`,
+ `templates.update`, the `emailCategory` filter on `templates.list`, and
+ `campaigns.create`. Rename the key in the body; the values are unchanged
+ except that the enum member **`HEADLESS` is now `SELF_MANAGED_UNSUBSCRIBE`** —
+ the old name said how the mail was built, the new one says what the recipient
+ gets, which is the fact a caller is choosing between.
+
+ Nothing is accepted under both names, deliberately: an alias would let a
+ half-migrated codebase keep working while the two spellings drifted apart.
+
+- **`events.record`'s payload field is now `payload`, not `data`.** Only the v1
+ write moved. **`events.track` is unaffected** and still takes `data`, because
+ it is the legacy `POST /api/track` and its body is a different schema that was
+ not part of this rename. The SDK documents what each endpoint actually accepts
+ rather than smoothing the two together — a shared name here would be a lie
+ about one of them.
+
+- **`Domain.mailFromStatus` is now `mailFromDomainStatus`** (and
+ `mail_from_domain_status` on the v1 document). It sits beside `mailFromDomain`
+ and is the status *of that domain*, which the old name did not say.
+
+- **`emails.get` returns a different body — read this one.** It used to hand
+ back the whole database row plus an `events` array that was the **wrong
+ relation**: the custom analytics events a caller records with `events.record`,
+ not the delivery history the operation has always promised. A caller polling
+ it for delivery state was reading somebody else's data and, if their project
+ recorded no custom events, an empty list that looked like "nothing has
+ happened yet".
+
+ It now returns an explicit field list, `events` as the delivery timeline
+ (oldest first), and `to` filled from the joined contact — a field the spec had
+ always declared and the response had never carried.
+
+ Keys that used to leak out of it and no longer do: `bodyHash`, `dedupKey`,
+ `idempotencyKey`, `linkMap`, `sesMessageId`, `sesInboundMessageId`, `body` and
+ `headers`. Four of those are ledger keys for deduplication and idempotency and
+ the rest are internal routing state or the rendered message; none was ever
+ documented. What to change: read `events.list` if you wanted custom events,
+ and keep your own copy of the body if you were reading it back out of here.
+
+- **`emails.list` and `emails.cancel_schedule` narrowed the same way.** All three
+ handlers on that surface were returning the whole database row and each had got
+ there separately; they share one field list now. The list was the widest of
+ them, since it leaked a page of rows at a time, and `cancel_schedule` returned
+ the `dedupKey` in the same response that released it. The same eight fields
+ named above are gone from both, and both now carry `to`.
+
+ `cancel_schedule` answers the single-email body the contract has always
+ published for it. The SDK had treated it as an empty envelope since 1.0, so
+ this is the type catching up to the document AND the route catching up to the
+ type.
+
+- **`sentAt`, `deliveredAt` and `bouncedAt` are now declared on the email body.**
+ They were reaching callers only because of the whole-row leak above and were in
+ no published schema, so the honest options were to declare them or drop them.
+ Declared: they are ordinary delivery facts and callers read them. Each is
+ nullable, and null means the transition has not happened.
+
+- **Engagement left the delivery status.** `OPENED`, `CLICKED` and `COMPLAINED`
+ are no longer delivery states, so they no longer appear in `email["status"]`
+ and are no longer accepted by the `status` filter on `emails.list`. The
+ remaining values are `PENDING`, `SENDING`, `SENT`, `DELIVERED`, `RECEIVED`,
+ `BOUNCED`, `FAILED`, `REJECTED`, `RENDERING_FAILURE`, `DELIVERY_DELAY` and
+ `CANCELLED`.
+
+ Read engagement from `openedAt` / `clickedAt` / `complainedAt` and the `opens`
+ / `clicks` counters instead. The two were one enum, which meant a message that
+ had been opened stopped reporting that it had been delivered — a status can
+ only hold one value, and delivery and engagement are not alternatives.
+
+- **The double-opt-in confirmation route moved** from `/api/lists/confirm` to
+ `/api/lists/confirm-subscription`. `lists.subscribe` documents that URL
+ because Sendly does not send the confirmation email — your application does —
+ so a caller who builds it by hand must change the path. The `confirmToken` in
+ the response is unchanged.
+
+- **`Domain.name` is now `Domain.domain`**, and the record no longer carries a
+ ready-made `dkim` list of `{type, name, value}` records. What SES actually
+ hands back is a list of tokens, so that is what is published: **`dkimTokens`**,
+ the strings to publish as CNAME records. The old shape implied Sendly knew the
+ full record set; it knew the tokens and was assembling the rest.
+
+- **`DomainVerificationStatus` reports one status per DNS record type.** `dkim`
+ and `mxRecords` are gone; `dkimStatus`, `spfStatus` and `dmarcStatus` take
+ their place, and `domain`, `status` and `mailFromDomain` are now required.
+ `status` is SES's own raw DKIM state (`Success`, `Pending`) and the three
+ `*Status` fields are this platform's DNS check — both are published because
+ they can disagree, and a single collapsed verdict hid which record was actually
+ failing.
+
+- **The legacy suppression list answers a bare body.** `GET /api/suppression`
+ returns `{"items", "nextCursor"}` with no `{"success", "data"}` envelope, where
+ it previously published `{"success", "data", "hasMore", "cursor"}`.
+ `suppression.list` hands the body back untouched, so read `page["items"]` and
+ `page["nextCursor"]`. `nextCursor` is `None` on the last page and is never
+ omitted.
+
+- **`webhooks.create` nests the endpoint beside the secret.** `data` is now
+ `{"webhook", "secret"}` rather than the webhook's fields spread alongside
+ `secret`. `created["data"]["secret"]` is unchanged; the endpoint's id moved to
+ `created["data"]["webhook"]["id"]`. Spreading a resource and a one-time
+ credential into one object made it impossible to hand the record onward without
+ carrying the secret with it.
+
+- **`Webhook.lastFour` is gone.** A webhook record now states that it never
+ carries a secret, and a four-character fragment of one is still a fragment of
+ one. Nothing identified an endpoint by it — `id` and `url` do that.
+
+### Added
+
+- **The `/api/v1` half of six resources that had only a legacy one.** Both
+ dialects stay reachable, so the versioned methods carry a `_v1` suffix:
+ - `contacts` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `update_v1`,
+ `delete_v1`, and `topic_preferences`.
+ - `lists` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `update_v1`,
+ `delete_v1`, and `start_validation_run`.
+ - `templates` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`,
+ `update_v1`, `delete_v1`.
+ - `domains` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `verify_v1`,
+ `delete_v1`, plus the legacy `assign_stream`.
+ - `webhooks` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `update_v1`,
+ `delete_v1`, `rotate_secret_v1`.
+ - `suppression` — `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`,
+ `delete_v1`.
+
+ The suffix is not decoration. The two halves answer the same question with
+ different envelopes (`{success, data}` versus the bare body), different field
+ cases (camelCase versus snake_case) and different error bodies (the legacy
+ envelope versus RFC 9457), so a call site that mixes them up reads a `data`
+ that is not there and raises a `KeyError` a long way from the mistake. Naming
+ them apart is what makes that impossible.
+
+ One difference inside suppression is worth knowing before you swap: the v1
+ path parameter is an **address**, and v1 answers `404 resource_not_found` for
+ an address that is not suppressed, where the legacy `suppression.get` answers
+ `200 {"suppressed": False}`. Both are definite; only one of them raises.
+
+- **`sendly.topics`** — `list`, `iter_list`, `create`, `get`, `update`,
+ `set_subscription`. The consent vocabulary a project mails against: a contact
+ subscribes to a topic rather than to a campaign, so switching one off silences
+ a whole audience. Two things a caller needs:
+ - **Subscribing somebody through the API does not bypass confirmation.**
+ `set_subscription(id, {"subscribed": True})` parks the contact at `pending`
+ and returns a `confirmation_url` that **your** application delivers, from
+ your own verified domain; nothing is mailed on the topic until someone opens
+ it. There is no parameter to skip that, because a subscription an API caller
+ asserts is not evidence the mailbox holder agreed.
+ - **A topic is archived, never deleted.** There is no `delete` method because
+ there is no delete route: a topic is where people's answers are recorded, so
+ deleting it would delete the choices they made against it.
+ `update(id, {"archived": True})` retires it and keeps them.
+
+- **`sendly.snippets`** — `create`, `list`, `get`, `update`, `delete`. Reusable
+ body fragments a template includes with `{{> name}}`, on the legacy dialect,
+ gated by the same `templates:*` scopes as the templates that include them — a
+ snippet is part of a template body rather than a resource with an audience of
+ its own. Deleting one does not break its templates: an absent snippet renders
+ as an empty string, like an absent variable.
+
+- **`sendly.validation`** — `validate_emails`, `get_run`, `list_results`,
+ `iter_list_results`. **Billed per address checked**: every entry in
+ `validate_emails({"emails": [...]})` costs money, so looping it over a contact
+ list is looping over your invoice. Validate a whole list with
+ `lists.start_validation_run`, a background job, and poll it with `get_run`.
+
+ A verdict of `unknown` is deliberately a distinct value from `undeliverable`:
+ it means DNS did not answer in time, so the address was **not checked**. That
+ separation exists so a DNS timeout is never grounds for deleting a contact.
+
+- **`sendly.deliverability`** — `diagnose`, `list_domain_stats`,
+ `iter_list_domain_stats`, `list_dmarc_reports`, `iter_list_dmarc_reports`.
+ `list_domain_stats` is per **recipient** domain (`gmail.com`, `outlook.com`) —
+ the domains you send **to** — which is the axis `diagnose` cannot report: its
+ project-wide rates hide one provider refusing nearly everything while the rest
+ of your mail is healthy. DMARC reports arrive only for a policy domain the
+ project has registered, and receivers send them on their own schedule, so **an
+ empty list is correct rather than broken**.
+
+- **`campaigns.list_failures`, `campaigns.iter_list_failures` and
+ `campaigns.retry_failed`.** `stats` says how many sends failed; only these say
+ who, and `reason` comes from a fixed vocabulary rather than the underlying
+ error text so it is stable enough to branch on. `retry_failed` re-drives
+ **only** the recipients whose send failed — nobody who already received the
+ campaign is mailed again, because each ledger row is claimed before it is
+ touched and a row whose email exists already is re-queued rather than re-sent.
+ Uniquely among v1 lists, `list_failures` also carries `total`: `retry_failed`
+ acts on that number, and `has_more` alone cannot tell you whether 3 or 30,000
+ sends failed.
+
+- **`workflows.get_graph`, `workflows.replace_graph`, `workflows.clone`,
+ `workflows.pause` and `workflows.resume`.**
+ - `replace_graph` is a `PUT` because a graph is replaced whole: nodes *plus*
+ the edges between them, so a partial edit to a step list has no meaning
+ without the transitions that reference it. An id you omit deletes that step
+ and its run history; it is refused with `409 conflict` while executions are
+ running.
+ - `clone` always creates the copy **disabled**, whatever the original was — a
+ clone exists to be reviewed, and one that started live would match the same
+ trigger events as its original from the moment it appeared.
+ - `pause` cancels every `RUNNING`/`WAITING` execution and reports how many in
+ `cancelled_executions`. `resume` re-opens the workflow to new runs and does
+ **not** restore the cancelled ones (`cancelled_executions` is always 0
+ there). That asymmetry is the point of having both:
+ `update(id, {"enabled": False})` stops new runs and leaves every in-flight
+ contact walking the graph, `pause` stops the sends already in flight, and
+ nothing puts them back.
+
+- **`mailboxes.send_message` and `mailboxes.draft_message`.** The mailbox
+ resource is no longer read-only.
+ - `send_message` **really sends**, as that mailbox's own address, over its own
+ domain, and the recipient can reply. There is no `from` field on purpose: a
+ route that sends under a customer's identity must not take that identity as
+ an argument. `body` is plain text and HTML is refused, so text becomes
+ markup in exactly one place. Needs `mailboxes:send`.
+ - `draft_message` asks Sendly's assistant to **write** text and hands it back.
+ It stores nothing and sends nothing — the response reports `sent: False`,
+ and no argument changes that — so it needs only `mailboxes:read`. A client
+ that may draft is not thereby a client that may mail your customers.
+
+- **Auto-pagination for every new cursor list.** The `iter_*` companions now
+ number seventeen: the six from 0.2.0 plus `campaigns.iter_list_failures`,
+ `contacts.iter_list_v1`, `deliverability.iter_list_dmarc_reports`,
+ `deliverability.iter_list_domain_stats`, `domains.iter_list_v1`,
+ `lists.iter_list_v1`, `suppression.iter_list_v1`, `templates.iter_list_v1`,
+ `topics.iter_list`, `validation.iter_list_results` and
+ `webhooks.iter_list_v1`.
+
+- **`intake_configured` on the DMARC report list**, and it is the field that
+ makes an empty page readable. `deliverability.list_dmarc_reports` answering
+ `"data": []` used to mean either "no receiver has reported a failure" or "this
+ deployment has no report intake mailbox, so nothing can ever arrive", and the
+ two were indistinguishable. `"intake_configured": False` is the second one.
+ Read it before telling anyone the domains are clean.
+
+- **`Suppression.scope`** — `PROJECT` or `GLOBAL`. Every record this API creates
+ or returns today is `PROJECT`; `GLOBAL` is a platform-wide block recorded
+ outside your project, which is why `suppression.get_v1` can answer `200` for an
+ address you never suppressed yourself.
+
+- **`Template.currentVersion`** — a counter incremented by an update that changes
+ the rendered content, and left alone by one that only renames. A campaign
+ records the version it sent, so this is how a caller tells "the template changed
+ since" from "the template was retitled".
+
+- **`Webhook.domains`** — the sending domains an endpoint is scoped to, empty
+ meaning every domain on the project. It was already enforced; it is now
+ readable, so a caller can see why an endpoint is quiet.
+
+- **`Webhook.previousSecretExpiresAt`** on the record itself, not only on the
+ rotation response. While a rotation is in flight it says when the OLD secret
+ stops being accepted, and it is `None` outside one — so a verifier can tell
+ from a plain read whether it is inside a dual-signature window.
+
+- **Every `{id}` path parameter declares `format: uuid`,** and the seven
+ operations that had no `404` published now publish one:
+ `GET /api/v1/contacts/{id}/topics`, `POST /api/v1/lists/{id}/validation-runs`,
+ `GET` and `PATCH /api/v1/topics/{id}`,
+ `POST /api/v1/topics/{id}/subscriptions`, `GET /api/v1/validation-runs/{id}`
+ and its `/results`. All seven answered `404 resource_not_found` already; the
+ contract now says so, which is what the error-handling examples are read from.
+
+### Fixed
+
+- **README: `contacts.upsert` and `contacts.update` were documented with a
+ `data` key.** The legacy contact body's custom-field map is `customFields`;
+ `data` was silently ignored, so the example looked like it worked and stored
+ nothing. (`lists.subscribe` really does take `data` — that one is unchanged.)
+- **README: the `segments.create` example's `condition` was not a filter
+ condition.** It showed `{"field": ..., "op": ..., "value": ...}`; the API
+ takes `{"logic", "groups"}`, each group holding `filters` of
+ `{"field", "operator", "value"}` with `operator` from a fixed vocabulary
+ (`equals`, `contains`, …). Copying the old example produced a `422`.
+- **README: the mailbox resource was described as read-only** in two places. It
+ is not, since `send_message` and `draft_message`; what stays out of reach is
+ the mailbox *lifecycle*, which is a different claim.
+
+### Notes
+
+- **Pagination is uniform again.** Every v1 list takes `after` and answers
+ `next_cursor`. `topics.list` and `validation.list_results` were the two
+ exceptions through 1.0 — they took `cursor` and answered `cursor` — and the
+ platform collapsed that to one dialect for this release, so both now route
+ through the shared cursor helper like every other collection. **This is
+ breaking for a caller driving those two by hand**: pass `after` instead of
+ `cursor`, and read `next_cursor` instead of `cursor`. Anyone using
+ `topics.iter_list` or `validation.iter_list_results` is unaffected. The helper
+ also picked up the "stop if a page repeats the cursor it was handed" guard
+ those two walkers had, so consolidating them dropped nothing.
+- **`NOT_SDK_CALLABLE` is unchanged.** Creating and deleting a mailbox, creating
+ and revoking an app password, the four API-key operations, and creating a
+ project still resolve the acting user from a session and answer `401` to any
+ API key. The two new mailbox methods are the opposite case — they publish
+ `ApiKeyAuth` outright.
+- **Nothing added here takes an `idempotency_key`.** The set of writes that
+ accept one is the same as in 1.0: `emails.send`, `emails.send_legacy`,
+ `emails.batch`, `contacts.create`, `contacts.upsert`, `contacts.bulk_create`,
+ `campaigns.create` and `campaigns.send`. `campaigns.retry_failed` is guarded
+ instead by a `409 conflict` on a retry already running, which is a better fit:
+ the thing to prevent is two concurrent walks, not a replayed request.
+
## [1.0.0] - 2026-09-02
The default send moves to the versioned API. Everything else in this release is
diff --git a/README.md b/README.md
index d8d6f88..f761b2f 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,10 @@
# Sendly Python SDK
-Official Python SDK for the [Sendly](https://sendly.now) REST API — transactional
-email, contacts, events, domains, templates, email verification, webhooks,
-suppression, and mailbox and project reads.
+Official Python SDK for the [Sendly](https://sendly.now) REST API —
+transactional email, contacts, lists, topics, events, domains, templates,
+snippets, email verification, address validation, deliverability reporting,
+webhooks, suppression, and mailbox reads plus the two composition calls an API
+key may drive.
[](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml)
@@ -72,6 +74,70 @@ receipt = sendly.emails.send(
print(receipt["id"], receipt["status"])
```
+### Upgrading from 1.0
+
+1.1 is mostly additive — four new resources and the `/api/v1` half of six more —
+but it also tracks a set of **wire-visible renames** that landed in the
+platform, so it is a breaking release. Do not deploy 1.1 against an API that has
+not taken the renamed wire yet: sending `type` where the API now expects
+`emailCategory` is a `422`, not a shrug.
+
+What to change, in the order a codebase usually hits it:
+
+- **`type` → `emailCategory` (legacy) / `email_category` (v1)** on templates and
+ campaigns. Affects `templates.create`, `templates.update`, the
+ `emailCategory` filter on `templates.list`, and `campaigns.create`. The enum
+ member `HEADLESS` is now `SELF_MANAGED_UNSUBSCRIBE`; `MARKETING` and
+ `TRANSACTIONAL` are unchanged.
+- **`data` → `payload`** in the body of `events.record` (the v1 write). The
+ legacy `events.track` is untouched and still takes `data` — the two endpoints
+ were renamed on different schedules, and this SDK reports what each one
+ actually accepts rather than papering over the difference.
+- **`mailFromStatus` → `mailFromDomainStatus`** on a domain, and
+ `mail_from_domain_status` on the v1 document.
+- **`emails.get` returns a different body** — see below.
+- **The double-opt-in confirmation route moved** from `/api/lists/confirm` to
+ `/api/lists/confirm-subscription`. Sendly has never sent that email for you,
+ so if you build the URL yourself — and `lists.subscribe` is documented on the
+ assumption that you do — change the path.
+
+#### `emails.get`, specifically
+
+It used to hand back the whole database row together with an `events` array that
+was the **wrong relation**: the custom analytics events a caller records with
+`events.record`, not the delivery history the operation has always promised.
+
+It now returns an explicit field list plus `events` as the delivery timeline
+(oldest first), and it fills `to` from the joined contact — which the spec had
+always declared and the response had never carried.
+
+Keys that used to leak out of it and no longer do: `bodyHash`, `dedupKey`,
+`idempotencyKey`, `linkMap`, `sesMessageId`, `sesInboundMessageId`, `body` and
+`headers`. Four of those are ledger keys for deduplication and idempotency; the
+rest are internal routing state or the rendered message itself. None of them was
+ever documented, and a caller reading them was reading Sendly's bookkeeping.
+
+If you were reading `events` from this call expecting custom events, read
+`events.list` instead. If you were reading the message body back out of it, keep
+your own copy — it is not published here.
+
+#### Engagement left the delivery status
+
+`OPENED`, `CLICKED` and `COMPLAINED` are no longer delivery states, so they no
+longer appear in `email["status"]` and are no longer accepted by the `status`
+filter on `emails.list`. A message is `PENDING`, `SENDING`, `SENT`,
+`DELIVERED`, `RECEIVED`, `BOUNCED`, `FAILED`, `REJECTED`, `RENDERING_FAILURE`,
+`DELIVERY_DELAY` or `CANCELLED`. Engagement is a separate axis:
+
+```python
+email = sendly.emails.get(email_id)["data"]
+delivered = email["status"] == "DELIVERED" # a delivery fact
+engaged = email["openedAt"] is not None or email["clicks"] > 0 # an engagement fact
+```
+
+The two used to be one enum, which meant an opened message stopped reporting
+that it had been delivered.
+
### Upgrading from 0.x
**1.0 repoints `emails.send` to the versioned `POST /api/v1/emails`.** It now
@@ -123,6 +189,35 @@ with Sendly() as sendly:
sendly.emails.send({...})
```
+## The resources
+
+Every resource hangs off the client. A `_v1` suffix means the method speaks the
+versioned dialect; the unsuffixed method of the same name on the same resource
+speaks the legacy one — see [Both dialects, one client](#both-dialects-one-client).
+
+| `sendly.*` | Methods |
+| --- | --- |
+| `emails` | `send`, `send_legacy`, `send_test`, `batch`, `list`, `get`, `cancel_schedule` |
+| `contacts` | `create`, `upsert`, `bulk_create`, `bulk_delete`, `list`, `get`, `update`, `delete`, `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `update_v1`, `delete_v1`, `topic_preferences` |
+| `lists` | `subscribe`, `unsubscribe`, `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `update_v1`, `delete_v1`, `start_validation_run` |
+| `topics` | `list`, `iter_list`, `create`, `get`, `update`, `set_subscription` |
+| `templates` | `create`, `list`, `get`, `update`, `delete`, `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `update_v1`, `delete_v1` |
+| `snippets` | `create`, `list`, `get`, `update`, `delete` |
+| `domains` | `create`, `list`, `get`, `verify`, `get_verification`, `start_setup`, `assign_stream`, `delete`, `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `verify_v1`, `delete_v1` |
+| `webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `list_calls`, `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `update_v1`, `delete_v1`, `rotate_secret_v1` |
+| `suppression` | `add`, `list`, `get`, `remove`, `list_v1`, `iter_list_v1`, `create_v1`, `get_v1`, `delete_v1` |
+| `events` | `track`, `record`, `list`, `iter_list`, `list_names`, `stats` |
+| `campaigns` | `list`, `iter_list`, `create`, `get`, `update`, `delete`, `send`, `cancel`, `pause`, `resume`, `stats`, `list_failures`, `iter_list_failures`, `retry_failed` |
+| `segments` | `list`, `iter_list`, `create`, `get`, `update`, `delete`, `list_contacts`, `iter_list_contacts` |
+| `workflows` | `list`, `iter_list`, `create`, `get`, `update`, `delete`, `list_executions`, `iter_list_executions`, `start_execution`, `cancel_execution`, `stats`, `get_graph`, `replace_graph`, `clone`, `pause`, `resume` |
+| `mailboxes` | `list`, `get`, `list_app_passwords`, `send_message`, `draft_message` |
+| `validation` | `validate_emails`, `get_run`, `list_results`, `iter_list_results` |
+| `deliverability` | `diagnose`, `list_domain_stats`, `iter_list_domain_stats`, `list_dmarc_reports`, `iter_list_dmarc_reports` |
+| `analytics` | `timeseries`, `campaigns`, `top_campaigns` |
+| `usage` | `get` |
+| `projects` | `get` |
+| `verify` | `email` |
+
## Usage by resource
### Emails
@@ -142,54 +237,207 @@ sendly.emails.send_legacy({"from": "a@you.com", "to": ["b@them.com", "c@them.com
sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "
Hi
"}]})
# List, get, cancel a scheduled send
-sendly.emails.list({"limit": 20, "status": "DELIVERED"})
-sendly.emails.get("em_123")
+page = sendly.emails.list({"limit": 20, "status": "DELIVERED"})
+for email in page["data"]:
+ print(email["id"], email["to"], email["status"])
+
sendly.emails.cancel_schedule("em_123")
```
+`emails.get` returns the email together with its **delivery** history, oldest
+first — not the custom events you record with `events.record`, which are read
+from `events.list`:
+
+```python
+email = sendly.emails.get("em_123")["data"]
+
+print(email["to"], email["status"], email["opens"], email["clicks"])
+for event in email["events"]:
+ print(event["timestamp"], event["status"])
+```
+
+`status` filters and reports the delivery lifecycle only. To find the messages
+somebody opened, read `openedAt` / `opens` on the rows — engagement is not a
+status.
+
### Contacts
```python
sendly.contacts.create({"email": "user@example.com", "subscribed": True})
-sendly.contacts.upsert({"email": "user@example.com", "data": {"plan": "pro"}})
+sendly.contacts.upsert({"email": "user@example.com", "customFields": {"plan": "pro"}})
sendly.contacts.list({"limit": 50, "search": "example.com"})
sendly.contacts.get("c_123")
-sendly.contacts.update("c_123", {"data": {"plan": "enterprise"}})
+sendly.contacts.update("c_123", {"customFields": {"plan": "enterprise"}})
sendly.contacts.delete("c_123")
sendly.contacts.bulk_create({"contacts": [{"email": "a@x.com"}, {"email": "b@x.com"}]})
sendly.contacts.bulk_delete({"emails": ["a@x.com"]})
```
+The `_v1` half manages the same contacts with snake_case bodies, cursor
+pagination and RFC 9457 errors:
+
+```python
+sendly.contacts.create_v1({"email": "user@example.com", "custom_fields": {"plan": "pro"}})
+sendly.contacts.get_v1("c_123")
+sendly.contacts.update_v1("c_123", {"custom_fields": {"plan": "enterprise"}})
+sendly.contacts.delete_v1("c_123")
+
+# `subscribed` is the string "true" / "false" here, not a bool — it is a query
+# parameter with three states, and omitting it means "both".
+for contact in sendly.contacts.iter_list_v1({"subscribed": "true"}):
+ print(contact["email"], contact["custom_fields"])
+```
+
+Two things about `update_v1` catch people out: `email` is not patchable at all
+(an address is the contact's identity, and rewriting it in place would change
+who every earlier send was addressed to), and `custom_fields` is **replaced, not
+merged** — send back every key you mean to keep.
+
+`topic_preferences` reads everything one contact has said they want:
+
+```python
+prefs = sendly.contacts.topic_preferences("c_123")
+
+# prefs["subscribed"] is the global marketing opt-out and OUTRANKS every topic:
+# False means no marketing reaches them whatever the rows below say.
+for topic in prefs["topics"]:
+ print(topic["key"], topic["subscribed"], topic["pending"])
+```
+
+Each topic's `subscribed` is the effective answer the send path reaches today,
+with the topic's `default_opt_in` already folded in, so a contact who has never
+answered still reads correctly.
+
+### Topics
+
+The consent vocabulary a project mails against. A contact subscribes to a topic
+rather than to a campaign, so switching one off silences a whole audience.
+
+```python
+topic = sendly.topics.create({
+ "key": "product-updates", # stable; survives a rename of `name`, not patchable
+ "name": "Product updates",
+ "default_opt_in": True,
+})
+
+result = sendly.topics.set_subscription(topic["id"], {"contact_id": "c_123", "subscribed": True})
+```
+
+**Subscribing somebody through the API does not bypass confirmation.**
+`"subscribed": True` parks the contact at `pending` and returns a
+`confirmation_url`; nothing is mailed on this topic until someone opens that
+link, and there is no parameter to skip it — a subscription a caller asserts is
+not evidence the mailbox holder agreed. Sendly does not send that email; **your
+application** delivers `result["confirmation_url"]`, from your own verified
+domain. `"subscribed": False` records the opt-out immediately.
+
+```python
+if result["status"] == "pending":
+ send_your_own_confirmation_email(result["confirmation_url"])
+```
+
+`default_opt_in` decides what silence means for a contact who never answers:
+True for a topic introduced over a list that already consented to hear from you,
+False for anything a person has to ask for.
+
+There is no `topics.delete`. A topic is where people's answers are recorded, so
+deleting it would delete the choices they made; archiving is the retire button
+and keeps them:
+
+```python
+sendly.topics.update(topic["id"], {"archived": True})
+for t in sendly.topics.iter_list({"include_archived": True}):
+ print(t["key"], t["archived"], t["subscribed_count"])
+```
+
### Events
```python
-# Track a custom event for a contact (accepts sk_* and pk_* keys)
+# Legacy /api/track — payload goes in `data`. Accepts sk_* and pk_* keys.
result = sendly.events.track({"event": "signup", "email": "user@example.com"})
print(result["contact"], result["timestamp"])
-# Attach an arbitrary payload and set subscription state
sendly.events.track({"event": "purchase", "email": "user@example.com",
"subscribed": True, "data": {"plan": "pro", "amount": 42}})
+
+# The v1 counterpart — payload goes in `payload`, and `contact_id` must already
+# exist: unlike the legacy endpoint, this one never creates contacts.
+sendly.events.record({"name": "purchase", "contact_id": "c_123",
+ "payload": {"plan": "pro", "amount": 42}})
```
+New integrations should prefer `record`, which also unlocks `events.list`,
+`events.list_names` and `events.stats`.
+
### Domains
```python
-sendly.domains.create({"domain": "mail.yourdomain.com", "region": "us-east-1"})
+domain = sendly.domains.create({"domain": "mail.yourdomain.com", "region": "us-east-1"})
+# Publish each token as a CNAME record before verification can succeed.
+print(domain["dkimTokens"])
+
sendly.domains.list()
sendly.domains.get("d_123")
sendly.domains.verify("d_123")
-sendly.domains.get_verification("d_123")
+
+status = sendly.domains.get_verification("d_123")
+# One status per record type, not one verdict for the domain.
+print(status["dkimStatus"], status["spfStatus"], status["dmarcStatus"])
+
sendly.domains.start_setup("d_123") # -> {"token", "connectUrl", "expiresAt"}
sendly.domains.delete("d_123")
```
+A domain reports each DNS record type separately — `dkimStatus`, `spfStatus` and
+`dmarcStatus` are each `NOT_CHECKED`, `PENDING`, `VERIFIED` or `FAILED`, and
+`lastHealthCheckAt` says when they were last filled. `status` on the verification
+response is a different thing: SES's own raw DKIM state (`Success`, `Pending`),
+which is why both are published rather than collapsed into one.
+`receivingEnabled` says whether inbound mail for the domain is routed to Sendly
+mailboxes.
+
`start_setup` returns the hand-off as the API returns it. Open `connectUrl` in a
browser to finish DNS setup at the registrar.
+`assign_stream` points a verified identity at one kind of traffic:
+
+```python
+sendly.domains.assign_stream("d_123", {
+ "stream": "TRANSACTIONAL",
+ "streamDefault": True,
+ "defaultFromAddress": "receipts@mail.yourdomain.com",
+})
+```
+
+Streams are enforced, not labelled: once assigned, a send of the other kind from
+this identity is refused with 403 — which is what keeps a campaign's complaint
+rate off the identity your password resets go out on. `"stream": None` unassigns
+it, returning it to carrying both. `streamDefault` demotes whichever identity
+currently holds the default for that stream, and `defaultFromAddress` has to be
+an address on this identity's own host.
+
+The `_v1` half is the same domains in the versioned dialect:
+
+```python
+domain = sendly.domains.create_v1({"domain": "mail.yourdomain.com", "region": "us-east-1"})
+sendly.domains.verify_v1(domain["id"]) # re-reads SES and DNS, then persists the answer
+for d in sendly.domains.iter_list_v1():
+ print(d["domain"], d["verified"], d["dkim_verified"])
+```
+
+`verified` is SES's verdict on the identity and is what decides whether mail can
+leave from this domain; `dkim_verified` is a separate fact — what the DNS health
+refresh last read — so the two disagree while a re-check is in flight and
+neither is a spelling of the other. `verify_v1` verifies nothing itself:
+verification happens in the domain's DNS when its owner publishes the DKIM
+records SES minted at creation, and this call asks SES what it currently sees.
+
### Mailboxes
-Reads only — see [What the SDK does not expose](#what-the-sdk-does-not-expose).
+Receiving mailboxes on the project's verified domains. The reads are reads; the
+two composition calls are not — `send_message` really sends. See
+[What the SDK does not expose](#what-the-sdk-does-not-expose) for what stays out
+of reach.
```python
sendly.mailboxes.list() # -> [mailbox, ...], not paginated
@@ -219,32 +467,225 @@ failures can list more than 10. Exceeding the cap is a `409`
(`SendlyConflictError`) from whatever creates the mailbox — which is not this
SDK, since mailbox creation needs a signed-in user.
-### Templates
+**`send_message` sends real mail**, from the mailbox in the path, over its own
+domain, and the recipient can reply to it:
+
+```python
+sent = sendly.mailboxes.send_message("mb_123", {
+ "to": ["customer@example.com"],
+ "subject": "Re: your order",
+ "body": "Shipping tomorrow — tracking to follow.",
+})
+print(sent["conversationId"], sent["messageId"])
+```
+
+There is no `from` field, on purpose: a route that sends under a customer's own
+identity must not take that identity as an argument. `body` is plain text and
+HTML is refused — Sendly renders the HTML part itself, escaping as it goes, so
+text becomes markup in exactly one place. Bcc recipients are delivered to but
+appear in no header, so the copy filed in the Sent folder does not record them.
+Refusals worth handling by name: 422 `RECIPIENT_SUPPRESSED`, 422
+`CONTENT_REFUSED`, and 503 `CONTENT_SCAN_UNAVAILABLE` (no verdict yet for a
+young project — nothing was sent, retry shortly). A mailbox may send 60 messages
+an hour here.
+
+**`draft_message` sends nothing.** It asks Sendly's assistant to write text and
+hands it back for you to review:
+
+```python
+draft = sendly.mailboxes.draft_message("mb_123", {
+ "mode": "draft", # or "rewrite", or "subject"
+ "brief": "Tell the customer their order ships tomorrow and apologise for the delay.",
+ "tone": "apologetic",
+})
+print(draft["subject"], draft["body"], draft["sent"]) # sent is always False
+```
+
+`sent: False` is reported rather than assumed, so a draft cannot be mistaken for
+a send. It stores nothing, reads no correspondence, and needs only
+`mailboxes:read` where sending needs `mailboxes:send` — a client that may draft
+is not thereby a client that may mail your customers. Everything you pass is
+treated strictly as data describing what to write, never as instructions to the
+model. Capped at 120 requests an hour per project; 502 means the model was
+unreachable.
+
+### Templates and snippets
```python
-sendly.templates.create({"name": "Welcome", "subject": "Welcome", "body": "Hi
",
- "from": "a@you.com", "type": "MARKETING"})
+sendly.templates.create({"name": "Welcome", "subject": "Welcome", "body": "Hi
{{> footer }}",
+ "from": "a@you.com", "emailCategory": "MARKETING"})
sendly.templates.list({"limit": 25}) # cursor pagination: pass {"cursor": ...} for the next page
sendly.templates.get("t_123")
sendly.templates.update("t_123", {"name": "Welcome v2"})
sendly.templates.delete("t_123")
```
-### Verify
+`emailCategory` — `type` before 1.1 — is `MARKETING`, `TRANSACTIONAL` or
+`SELF_MANAGED_UNSUBSCRIBE` (the member that used to be called `HEADLESS`). It
+defaults to `MARKETING`, and it is also the legacy list filter:
+`templates.list({"emailCategory": "MARKETING"})`.
+
+A template carries `currentVersion`, a counter an update increments only when it
+changes the **rendered content** — a rename leaves it alone. A campaign records
+the version it sent, so comparing the two is how you tell "the template changed
+since this went out" from "somebody retitled it".
+
+On the `_v1` methods the same field is `email_category`:
+
+```python
+template = sendly.templates.create_v1({"name": "Welcome", "subject": "Welcome",
+ "body": "Hi
", "from": "a@you.com",
+ "email_category": "MARKETING"})
+for t in sendly.templates.iter_list_v1({"search": "welcome"}):
+ print(t["name"], t["version"])
+```
+
+Touching `subject`, `body`, `from`, `from_name` or `reply_to` in `update_v1`
+snapshots the previous content into version history and increments `version`;
+touching only `name`, `description` or `email_category` does not, because
+neither is content a send would have rendered. `delete_v1` is refused with 409
+`conflict` while a workflow step or an active campaign still points at the
+template.
+
+A **snippet** is a reusable fragment a template pulls in with `{{> name}}`.
+`name` is the literal identifier templates include, unique within the project,
+so a clash answers 409:
+
+```python
+sendly.snippets.create({"name": "footer", "description": "Address block",
+ "body": "
Acme Inc, 1 Example Way
"})
+page = sendly.snippets.list({"limit": 25, "search": "footer"})
+print(len(page["data"]["data"]), page["data"]["hasMore"])
+
+sendly.snippets.get("s_123")
+sendly.snippets.update("s_123", {"body": "
Acme Inc
"})
+sendly.snippets.delete("s_123")
+```
+
+Snippets are gated by the same `templates:*` scopes as the templates that
+include them, because a snippet is part of a template body rather than a
+resource with an audience of its own. Deleting one does not break the templates
+that include it — an absent snippet renders as an empty string, like an absent
+variable.
+
+### Verify and validate
+
+`verify.email` is the free single-address check — syntax, MX, disposable
+domains, plus-addressing:
```python
-# Validate an email address (syntax, MX, disposable domains, plus-addressing).
-# Open endpoint — the SDK still sends your API key, which the API ignores.
result = sendly.verify.email({"email": "user@example.com"})
if not result["valid"]:
print("Rejected:", result.get("reason"))
```
+`validation` is the other thing entirely, and **it is billed per address
+checked**. Every entry in `emails` costs money, so looping it over a contact
+list is looping over your invoice:
+
+```python
+batch = sendly.validation.validate_emails({"emails": ["user@example.com", "typo@exmaple.com"]})
+
+for result in batch["results"]:
+ # Branch on `verdict`, never on the flags: `is_personal` (Gmail, Outlook) and
+ # `is_role_address` (support@) describe ordinary, deliverable addresses.
+ print(result["email"], result["verdict"])
+```
+
+At most 50 addresses per call. That ceiling is a latency bound, not a payload
+one: every distinct domain in the batch costs a DNS round trip. To check a whole
+list, start the background run instead — one call, then poll:
+
+```python
+run = sendly.lists.start_validation_run("l_123")
+
+progress = sendly.validation.get_run(run["id"])
+# Finished when status is "completed" or "failed" — never when a percentage
+# reaches 100, because there is deliberately no total to divide by: a list
+# changes size while a run walks it.
+print(progress["status"], progress["processed_count"], progress["undeliverable_count"])
+
+for result in sendly.validation.iter_list_results(run["id"], {"verdict": "undeliverable"}):
+ print(result["email"], result["contact_id"], result["reasons"])
+```
+
+A verdict of `unknown` is deliberately a separate value from `undeliverable`: it
+means DNS did not answer in time, so that address was **not checked**. Deleting
+a contact on `unknown` deletes a live one over a network hiccup. `undeliverable`
+is the page to read before acting on a run; `unknown` is the one never to act
+on.
+
+### Deliverability
+
+```python
+diagnosis = sendly.deliverability.diagnose({
+ "domain": "mail.yourdomain.com", # required — this endpoint answers about one domain
+ "address": "user@example.com", # optional RECIPIENT to check alongside it
+ "window_days": 7,
+})
+
+# `findings` is worst first, and an empty list means nothing here explains a
+# delivery problem. Branch on a finding's `code`, never on its prose.
+for finding in diagnosis["findings"]:
+ print(finding["severity"], finding["code"], finding["remedy"])
+```
+
+Nothing there is looked up live: the DNS statuses are the verification refresh
+job's cached results, and `identity.last_checked_at` says when they were filled.
+`recent_delivery` is project-wide rather than per-domain — its own `scope` field
+says so — because an email row records no sending domain.
+
+`list_domain_stats` is the axis `diagnose` cannot report: outcomes broken out by
+**recipient** domain and UTC day. These are the domains you send **to** —
+`gmail.com`, `outlook.com` — not the domains you send from, and they are how you
+catch one provider refusing nearly everything while the rest of your mail is
+healthy.
+
+```python
+for row in sendly.deliverability.iter_list_domain_stats({"limit": 100}):
+ print(row["day"], row["domain"], row["delivered"], row["bounced"], row["computed_at"])
+```
+
+The counts come from an hourly rollup over a rolling 30-day window, not from a
+query run on request; each row's `computed_at` says when it was last rebuilt. No
+rate is published, because a rate over three sends is not information.
+
+`list_dmarc_reports` returns the DMARC aggregate (RUA) reports receiving
+providers have sent about your domains. **An empty list is the correct answer,
+not a bug**, until a policy domain is registered in this project and its DMARC
+record names an address we receive — and receivers send on their own schedule,
+typically once a day.
+
+```python
+reports = sendly.deliverability.list_dmarc_reports({"limit": 20})
+
+# Which kind of empty is this? False means no intake mailbox exists, so no
+# report can ever arrive — the feature is off, your domains are not "clean".
+if not reports["intake_configured"]:
+ print("DMARC report intake is not configured on this deployment")
+
+for report in reports["data"]:
+ print(report["org_name"], report["policy_domain"], report["pass_count"], report["fail_count"])
+```
+
+`intake_configured` exists because the two empty lists are otherwise
+indistinguishable, and reporting "no DMARC failures" off a feature that was never
+switched on is the worse of the two mistakes. Read the flag before you tell
+anyone the domains are healthy.
+
+`pass_count` counts DMARC **alignment** taken from `policy_evaluated`, not raw
+authentication results — a message can pass SPF for a domain that is not the one
+in its From header, which is exactly the case DMARC exists to catch.
+
### Webhooks
```python
created = sendly.webhooks.create({"url": "https://you.com/hook", "eventTypes": ["email.delivered"]})
# Store the signing secret now — it is only returned in full at creation/rotation.
+print(created["data"]["secret"])
+# The endpoint sits beside it rather than spread around it.
+print(created["data"]["webhook"]["id"])
+
sendly.webhooks.list()
sendly.webhooks.get("w_123")
sendly.webhooks.update("w_123", {"status": "PAUSED"})
@@ -253,15 +694,77 @@ sendly.webhooks.list_calls("w_123", {"limit": 20})
sendly.webhooks.delete("w_123")
```
+On v1 the same registration returns the secret beside the webhook, and rotation
+tells you when the outgoing one stops working:
+
+```python
+created = sendly.webhooks.create_v1({"url": "https://you.com/hook",
+ "event_types": ["email.delivered", "email.bounced"]})
+webhook, secret = created["webhook"], created["secret"]
+
+rotated = sendly.webhooks.rotate_secret_v1(webhook["id"])
+print(rotated["secret"], rotated["previous_secret_expires_at"])
+```
+
+`create_v1` and `rotate_secret_v1` are the only two responses that ever carry a
+signing secret; no read endpoint hands it back, so a secret you lose is replaced
+by rotating rather than recovered. The outgoing secret is not cut off at once —
+it keeps verifying until `previous_secret_expires_at`, and every delivery inside
+that window carries **both** signatures, so a verifier can be redeployed without
+dropping an event. On `update_v1`, `event_types` **replaces** the stored
+subscription list rather than merging into it, so an event you omit is
+unsubscribed.
+
+A webhook record carries `domains` — the sending domains this endpoint is scoped
+to, where an empty list means every domain on the project — and, while a rotation
+is in flight, `previousSecretExpiresAt`. A record never carries a secret or any
+fragment of one.
+
### Suppression
```python
sendly.suppression.add({"email": "bounce@example.com", "reason": "MANUAL"})
-sendly.suppression.list({"reason": "MANUAL", "limit": 100})
-sendly.suppression.get("bounce@example.com")
+
+# Alone among the legacy reads, this one answers no {"success", "data"}
+# envelope — the page IS the body.
+page = sendly.suppression.list({"reason": "MANUAL", "limit": 100})
+for record in page["items"]:
+ print(record["email"], record["reason"], record["scope"])
+
+sendly.suppression.get("bounce@example.com") # -> {"suppressed": False} when it is not
sendly.suppression.remove("bounce@example.com")
```
+`scope` is `PROJECT` on every record this API creates or returns today; `GLOBAL`
+is reserved for a platform-wide block recorded outside your project.
+
+The `_v1` half addresses a record by the **address itself** and answers
+definitively either way — 200 means suppressed and says why, 404
+`resource_not_found` means it is not on the list. That is the difference from
+the legacy `suppression.get`, which answers `200 {"suppressed": False}` for an
+address nobody suppressed:
+
+```python
+from sendly import SendlyNotFoundError
+
+try:
+ record = sendly.suppression.get_v1("bounce@example.com")
+ print("suppressed:", record["reason"], record["source"])
+except SendlyNotFoundError:
+ pass # not suppressed — mail may flow
+```
+
+Suppressing is idempotent and the first `reason` wins: an already-suppressed
+address answers with the existing record, so a later manual entry cannot
+overwrite what an SES bounce recorded. `source` is not accepted in the body — it
+is derived from the credential, so a record's provenance cannot be dressed up as
+a deliverability fact.
+
+`delete_v1` is the one call on this surface that can put mail back into an inbox
+that asked you to stop, and it does **not** clear AWS SES's own account-level
+suppression list: an address SES suppressed after a hard bounce stays
+undeliverable through SES even once this record is gone.
+
### Lists
```python
@@ -271,7 +774,9 @@ 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']}"
+ confirm_url = (
+ f"https://api.sendly.now/api/lists/confirm-subscription?token={result['confirmToken']}"
+ )
# Re-subscribing an address that opted out needs an explicit opt-in, or the call
# fails with 409 RESUBSCRIBE_CONFIRMATION_REQUIRED.
@@ -280,11 +785,33 @@ sendly.lists.subscribe("l_123", {"email": "user@example.com", "allowResubscribe"
sendly.lists.unsubscribe("l_123", {"email": "user@example.com"})
```
+The path is `/api/lists/confirm-subscription` as of 1.1; it was
+`/api/lists/confirm` before. The token is valid for 24 hours.
+
+Managing the lists themselves is the `_v1` half:
+
+```python
+lst = sendly.lists.create_v1({"name": "Newsletter", "double_opt_in": True})
+sendly.lists.update_v1(lst["id"], {"redirect_url": "https://you.com/thanks"})
+sendly.lists.get_v1(lst["id"])
+sendly.lists.delete_v1(lst["id"]) # removes the list, not its contacts
+
+for l in sendly.lists.iter_list_v1({"limit": 50}):
+ print(l["name"], l["member_count"])
+```
+
+Turning `double_opt_in` on does not make Sendly send anything — it only changes
+`subscribe` to create the membership as `PENDING` and hand back the
+`confirmToken` your application delivers. `member_count` counts memberships in
+*any* status, `PENDING` and `UNSUBSCRIBED` included, so it is not the size of
+the audience a campaign would reach.
+
## 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:
+`campaigns`, `segments`, `workflows`, `topics`, `validation`, `deliverability`,
+`analytics` and `usage` — plus the v1 methods on `events`, `contacts`, `lists`,
+`templates`, `domains`, `webhooks` and `suppression` — 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.
@@ -292,6 +819,30 @@ key; two differences worth knowing:
classes as the legacy surface, with two extra fields — see
[Error handling](#error-handling).
+### Both dialects, one client
+
+Six resources — contacts, lists, templates, domains, webhooks and suppression —
+answer on both surfaces, so their v1 methods carry a `_v1` suffix:
+`contacts.list` is the legacy one, `contacts.list_v1` the versioned one.
+
+The suffix is not decoration. The two methods answer the same question with
+different envelopes, different field cases and different error bodies, and a
+call site that mixes them up reads a `data` that is not there:
+
+```python
+legacy = sendly.contacts.list({"limit": 20})
+legacy["data"]["data"] # the contacts, inside the {success, data} envelope
+legacy["data"]["nextCursor"] # camelCase
+
+v1 = sendly.contacts.list_v1({"limit": 20})
+v1["data"] # the contacts — the bare body IS the list envelope
+v1["next_cursor"] # snake_case
+```
+
+Legacy methods keep working and nothing about them changed in 1.1. New code
+should reach for the `_v1` ones: they are the surface the contract is versioned
+against, and they carry `request_id` on every failure.
+
### Campaigns
```python
@@ -302,6 +853,7 @@ campaign = sendly.campaigns.create(
"body": "Hello
",
"from": "team@you.com",
"audience_type": "ALL",
+ "email_category": "MARKETING", # was `type` before 1.1
},
idempotency_key="august-launch",
)
@@ -318,6 +870,31 @@ stats = sendly.campaigns.stats(campaign["id"])
print(stats["delivered"], stats["open_rate"])
```
+`stats` says how many sends failed; only `list_failures` says who:
+
+```python
+failures = sendly.campaigns.list_failures(campaign["id"], {"limit": 100})
+print(failures["total"], "recipients did not receive it")
+
+for failure in sendly.campaigns.iter_list_failures(campaign["id"]):
+ print(failure["email"], failure["reason"], failure["failed_at"])
+
+retry = sendly.campaigns.retry_failed(campaign["id"])
+print("re-queued", retry["queued"])
+```
+
+`reason` comes from a fixed vocabulary rather than the underlying error text, so
+it is stable enough to branch on; it is `None` on rows recorded before reasons
+were captured.
+
+`retry_failed` re-drives **only** the recipients whose send failed — nobody who
+already received the campaign is mailed a second time, because each ledger row
+is claimed before it is touched and a row whose email exists already is
+re-queued rather than re-sent. The walk runs in the background, so the call
+returns as soon as it is queued, reporting how many failed rows it was started
+for. Only a `SENT` campaign qualifies; a retry already running answers 409
+`conflict`.
+
### Pagination
Every v1 list answers `{data, has_more, next_cursor}` — an opaque forward-only
@@ -344,31 +921,43 @@ for contact in sendly.segments.iter_list_contacts("seg_123"):
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
+the query fixed and only advances the cursor.
+
+Through 1.0 there were two dialects: `topics.list` and
+`validation.list_results` took `cursor` and answered `cursor` where every other
+v1 list took `after`. The platform collapsed that for 1.1, so there is one shape
+to learn and one to write. If you were driving either of those two by hand, pass
+`after` and read `next_cursor`. (The LEGACY `/api/*` lists are a separate
+surface and still take `cursor` — that has not changed.)
+
+The seventeen iterators: `campaigns.iter_list`, `campaigns.iter_list_failures`,
+`contacts.iter_list_v1`, `deliverability.iter_list_dmarc_reports`,
+`deliverability.iter_list_domain_stats`, `domains.iter_list_v1`,
+`events.iter_list`, `lists.iter_list_v1`, `segments.iter_list`,
+`segments.iter_list_contacts`, `suppression.iter_list_v1`,
+`templates.iter_list_v1`, `topics.iter_list`, `validation.iter_list_results`,
+`webhooks.iter_list_v1`, `workflows.iter_list` and
+`workflows.iter_list_executions`. The analytics endpoints and
`events.list_names` / `events.stats` return a bounded aggregate rather than a
-cursor, so they have no iterator.
+cursor, so they have no iterator. `campaigns.list_failures` is the one cursor
+list that also carries `total`, because `retry_failed` acts on that number and
+`has_more` alone cannot tell you whether 3 or 30,000 sends failed.
-### Segments, workflows, events, analytics, usage, projects
+### Segments, events, analytics, usage, projects
```python
-segment = sendly.segments.create({"name": "Power users", "type": "DYNAMIC",
- "condition": {"field": "plan", "op": "eq", "value": "pro"}})
+segment = sendly.segments.create({
+ "name": "Power users",
+ "type": "DYNAMIC",
+ "condition": {
+ "logic": "AND",
+ "groups": [
+ {"filters": [{"field": "customFields.plan", "operator": "equals", "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"})
@@ -384,6 +973,68 @@ project = sendly.projects.get()
print(project["sandbox_address"])
```
+### Workflows
+
+```python
+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"})
+```
+
+`get_graph` returns every step — including the `TRIGGER` entry node — plus the
+directed transitions between them, and that body is accepted verbatim by
+`replace_graph`:
+
+```python
+graph = sendly.workflows.get_graph(workflow["id"])
+graph["steps"][0]["config"] # stored exactly as authored, camelCase keys and all
+
+sendly.workflows.replace_graph(workflow["id"], {
+ "steps": graph["steps"],
+ "transitions": graph["transitions"],
+})
+```
+
+`replace_graph` is a **PUT**, and that is the point: a graph is nodes *plus* the
+edges between them, so a partial edit to a step list has no meaning without the
+transitions that reference it — half-applied, it would leave steps pointing at
+steps that no longer exist. Ids decide the outcome per step: one you send is
+updated in place, a fresh uuid creates a step, and an id you omit deletes that
+step *and its run history*. Exactly one step must be a `TRIGGER`, every
+transition must name steps in the same document, and no step may point at
+itself. It is refused with 409 `conflict` while the workflow has running
+executions — those runs are standing on the steps being replaced.
+
+`clone` copies a workflow and its whole graph. The copy is **always created
+disabled**, whatever the original was: a clone exists to be reviewed, and one
+that started live would match the same trigger events as its original from the
+moment it appeared.
+
+```python
+copy = sendly.workflows.clone(workflow["id"], {"name": "Welcome (v2 test)"})
+```
+
+`pause` and `resume` are deliberately asymmetric:
+
+```python
+paused = sendly.workflows.pause(workflow["id"])
+print("cancelled", paused["cancelled_executions"], "in-flight runs")
+
+resumed = sendly.workflows.resume(workflow["id"])
+print(resumed["cancelled_executions"]) # always 0
+```
+
+**Pausing cancels every `RUNNING`/`WAITING` execution** and reports how many —
+that is what separates it from `update(id, {"enabled": False})`, which only
+stops new runs starting and leaves every in-flight contact walking the graph,
+next delay still expiring, next email still sending. **Resuming re-opens the
+workflow to new runs and does not restore the cancelled ones.** The cancellation
+is terminal; there is no undo, so pause when you mean to stop the sends already
+in flight and disable when you only mean to close the door. `resume` is refused
+with `422 validation_error` while any step is still unconfigured.
+
### Emails: `send` vs `send_legacy`
The same split as `events.track` / `events.record`, resolved the other way
@@ -428,6 +1079,20 @@ and the same daily and trust-tier caps as a real send. It takes no
bounds it, and "send me another one" is the normal second call rather than a
mistake worth deduplicating.
+### Idempotency
+
+Pass `idempotency_key` on the writes that accept one — `emails.send`,
+`emails.send_legacy`, `emails.batch`, `contacts.create`, `contacts.upsert`,
+`contacts.bulk_create`, `campaigns.create` and `campaigns.send`. Replays within
+24 hours return the original result instead of acting twice.
+
+Nothing added in 1.1 takes a key. The v1 creates are either naturally idempotent
+on their own key or cheap to repeat, and `campaigns.retry_failed` is guarded
+instead by a 409 on a retry already running — the thing to prevent there is two
+concurrent walks, not a replayed request. `events.record` takes none because
+events are append-only and high-volume; `emails.send_test` takes none for the
+reason above.
+
### What the SDK does not expose
An API key resolves no user, and a handful of routes resolve the acting project
@@ -441,8 +1106,9 @@ declarations, in both directions. They are: creating and deleting a mailbox,
creating and revoking an app password, all four API-key operations, and creating
a project. Use the dashboard or an OAuth connection for those.
-Mailbox **reads** are exposed — their membership check is conditional, so a key
-really can call them.
+Mailbox **lifecycle** is what stays out of reach — not the mailbox resource as a
+whole. The three reads have a conditional membership check, and `send_message` /
+`draft_message` publish `ApiKeyAuth` outright, so a key really can call all five.
## Error handling
@@ -481,6 +1147,10 @@ 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`.
+Note that `SendlyNotFoundError` from `suppression.get_v1` is an ordinary answer,
+not a failure: it is how that route says "this address is not suppressed". Catch
+it rather than logging it.
+
### v1 errors (RFC 9457)
The `/api/v1` surface reports failures as `application/problem+json` documents.
diff --git a/pyproject.toml b/pyproject.toml
index 371c430..54e64e4 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 = "1.0.0"
+version = "1.1.0"
description = "Official Sendly Python SDK"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/src/sendly/__init__.py b/src/sendly/__init__.py
index f4a9b1b..e1c0389 100644
--- a/src/sendly/__init__.py
+++ b/src/sendly/__init__.py
@@ -33,6 +33,7 @@
from sendly.resources.analytics import AnalyticsResource
from sendly.resources.campaigns import CampaignsResource
from sendly.resources.contacts import ContactsResource
+from sendly.resources.deliverability import DeliverabilityResource
from sendly.resources.domains import DomainsResource
from sendly.resources.emails import EmailsResource
from sendly.resources.events import EventsResource
@@ -40,9 +41,12 @@
from sendly.resources.mailboxes import MailboxesResource
from sendly.resources.projects import ProjectsResource
from sendly.resources.segments import SegmentsResource
+from sendly.resources.snippets import SnippetsResource
from sendly.resources.suppression import SuppressionResource
from sendly.resources.templates import TemplatesResource
+from sendly.resources.topics import TopicsResource
from sendly.resources.usage import UsageResource
+from sendly.resources.validation import ValidationResource
from sendly.resources.verify import VerifyResource
from sendly.resources.webhooks import WebhooksResource
from sendly.resources.workflows import WorkflowsResource
@@ -57,6 +61,7 @@
"AnalyticsResource",
"CampaignsResource",
"ContactsResource",
+ "DeliverabilityResource",
"DomainsResource",
"EmailsResource",
"EventsResource",
@@ -74,9 +79,12 @@
"SendlyRateLimitError",
"SendlyServerError",
"SendlyValidationError",
+ "SnippetsResource",
"SuppressionResource",
"TemplatesResource",
+ "TopicsResource",
"UsageResource",
+ "ValidationResource",
"VerifyResource",
"WebhooksResource",
"WorkflowsResource",
diff --git a/src/sendly/client.py b/src/sendly/client.py
index fbf398d..419a256 100644
--- a/src/sendly/client.py
+++ b/src/sendly/client.py
@@ -36,6 +36,7 @@
from sendly.resources.analytics import AnalyticsResource
from sendly.resources.campaigns import CampaignsResource
from sendly.resources.contacts import ContactsResource
+from sendly.resources.deliverability import DeliverabilityResource
from sendly.resources.domains import DomainsResource
from sendly.resources.emails import EmailsResource
from sendly.resources.events import EventsResource
@@ -43,9 +44,12 @@
from sendly.resources.mailboxes import MailboxesResource
from sendly.resources.projects import ProjectsResource
from sendly.resources.segments import SegmentsResource
+from sendly.resources.snippets import SnippetsResource
from sendly.resources.suppression import SuppressionResource
from sendly.resources.templates import TemplatesResource
+from sendly.resources.topics import TopicsResource
from sendly.resources.usage import UsageResource
+from sendly.resources.validation import ValidationResource
from sendly.resources.verify import VerifyResource
from sendly.resources.webhooks import WebhooksResource
from sendly.resources.workflows import WorkflowsResource
@@ -59,7 +63,7 @@
__all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
#: Package version. Kept in sync with ``pyproject.toml``.
-SDK_VERSION = "1.0.0"
+SDK_VERSION = "1.1.0"
#: Default production API base. Override via ``base_url`` for staging/self-hosted.
DEFAULT_BASE_URL = "https://api.sendly.now"
@@ -137,6 +141,7 @@ def __init__(
self.webhooks = WebhooksResource(self)
self.suppression = SuppressionResource(self)
self.lists = ListsResource(self)
+ self.snippets = SnippetsResource(self)
# Reads only -- the mailbox writes need a user, which an API key is not.
self.mailboxes = MailboxesResource(self)
# /api/v1 surface. Same client, same auth; bare resource bodies instead
@@ -147,6 +152,9 @@ def __init__(
self.analytics = AnalyticsResource(self)
self.usage = UsageResource(self)
self.projects = ProjectsResource(self)
+ self.topics = TopicsResource(self)
+ self.validation = ValidationResource(self)
+ self.deliverability = DeliverabilityResource(self)
def request(
self,
diff --git a/src/sendly/resources/_pagination.py b/src/sendly/resources/_pagination.py
index 70af8b1..1c5cc0f 100644
--- a/src/sendly/resources/_pagination.py
+++ b/src/sendly/resources/_pagination.py
@@ -25,8 +25,11 @@ def iterate_cursor(
``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.
+ ``None``, when a page carries no ``data`` list, and when a page hands back
+ the very cursor it was given -- so a malformed or stuck response ends the
+ walk instead of looping forever. The last of those came from the two
+ hand-rolled walkers this helper absorbed in 1.1; consolidating them must not
+ drop a stop condition the resources that had it were relying on.
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
@@ -44,6 +47,6 @@ def iterate_cursor(
if not page.get("has_more"):
return
cursor = page.get("next_cursor")
- if not cursor:
+ if not cursor or cursor == params.get("after"):
return
params = {**params, "after": cursor}
diff --git a/src/sendly/resources/campaigns.py b/src/sendly/resources/campaigns.py
index accde8d..8367348 100644
--- a/src/sendly/resources/campaigns.py
+++ b/src/sendly/resources/campaigns.py
@@ -14,8 +14,10 @@
from sendly.types import (
Body,
CampaignDeleted,
+ CampaignFailureListV1,
CampaignList,
CampaignRecord,
+ CampaignRetryFailedV1,
CampaignStats,
JSONDict,
Query,
@@ -133,3 +135,44 @@ def stats(self, id: str) -> CampaignStats:
method="GET", path=f"/api/v1/campaigns/{encode_path_segment(id)}/stats"
)
return response
+
+ def list_failures(self, id: str, query: Query | None = None) -> CampaignFailureListV1:
+ """The recipients this campaign did not reach, and why.
+
+ :meth:`stats` says how many sends failed; only this says who. ``reason``
+ comes from a fixed vocabulary rather than the underlying error text, so
+ it is stable enough to branch on, and is ``None`` on rows recorded
+ before reasons were captured.
+
+ Cursor-paginated (``limit`` / ``after``) like every other v1 list, but
+ uniquely it also carries ``total``: :meth:`retry_failed` acts on that
+ number, and ``has_more`` alone cannot tell you whether 3 or 30,000 sends
+ failed.
+ """
+ response: CampaignFailureListV1 = self._client.request(
+ method="GET",
+ path=f"/api/v1/campaigns/{encode_path_segment(id)}/failures",
+ query=query,
+ )
+ return response
+
+ def iter_list_failures(self, id: str, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every failed send across pages, one recipient at a time."""
+ return iterate_cursor(lambda params: self.list_failures(id, params), query)
+
+ def retry_failed(self, id: str) -> CampaignRetryFailedV1:
+ """Re-drive only the recipients whose send failed.
+
+ Nobody who already received the campaign is mailed a second time: each
+ ledger row is claimed before it is touched, and a row whose email exists
+ already is re-queued rather than re-sent.
+
+ The walk runs in the background, so this returns as soon as it is
+ queued, reporting ``queued`` -- how many failed rows it was started for.
+ Only a ``SENT`` campaign qualifies (400 ``validation_error`` otherwise),
+ and a retry already running answers 409 ``conflict``. Takes no body.
+ """
+ response: CampaignRetryFailedV1 = self._client.request(
+ method="POST", path=f"/api/v1/campaigns/{encode_path_segment(id)}/retry-failed"
+ )
+ return response
diff --git a/src/sendly/resources/contacts.py b/src/sendly/resources/contacts.py
index ef74c12..4ed220f 100644
--- a/src/sendly/resources/contacts.py
+++ b/src/sendly/resources/contacts.py
@@ -5,20 +5,35 @@
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,
+ ContactDeletedV1,
ContactListResponse,
+ ContactListV1,
ContactRecord,
+ ContactTopicPreferencesV1,
+ ContactV1,
JSONDict,
Query,
)
class ContactsResource:
- """Create, query, and manage contacts."""
+ """Create, query, and manage contacts, on both surfaces.
+
+ The unsuffixed methods speak the legacy ``/api/*`` dialect — camelCase
+ bodies inside a ``{success, data}`` envelope the SDK unwraps. The
+ ``_v1``-suffixed methods speak ``/api/v1``: bare snake_case bodies, cursor
+ pagination, and RFC 9457 problem documents on error. Both answer the same
+ questions, so the suffix is there to keep a call site from confusing one for
+ the other.
+ """
def __init__(self, client: Sendly) -> None:
self._client = client
@@ -91,3 +106,86 @@ def delete(self, id: str) -> None:
self._client.request(
method="DELETE", path=f"/api/contacts/{encode_path_segment(id)}", no_content=True
)
+
+ def list_v1(self, query: Query | None = None) -> ContactListV1:
+ """List contacts on the ``/api/v1`` surface.
+
+ Accepts ``limit`` (1-100, default 20), ``after`` (opaque cursor),
+ ``search`` (case-insensitive substring on the address) and
+ ``subscribed`` (the string ``"true"`` or ``"false"``), and answers
+ ``{data, has_more, next_cursor}``. Hold the filters steady across one
+ walk — the cursor encodes them, and changing one mid-pagination is
+ answered with 422 ``validation_error`` telling you to restart from the
+ first page.
+ """
+ response: ContactListV1 = self._client.request(
+ method="GET", path="/api/v1/contacts", query=query
+ )
+ return response
+
+ def iter_list_v1(self, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every v1 contact across pages, following the cursor for you."""
+ return iterate_cursor(self.list_v1, query)
+
+ def create_v1(self, body: Body) -> ContactV1:
+ """Create a contact. Requires ``email``.
+
+ ``subscribed`` defaults to true server-side, and ``custom_fields`` is
+ arbitrary JSON that templates read back as ``{{ variables }}``.
+ """
+ response: ContactV1 = self._client.request(
+ method="POST", path="/api/v1/contacts", body=body
+ )
+ return response
+
+ def get_v1(self, id: str) -> ContactV1:
+ """Fetch a single contact by id.
+
+ v1 has no lookup-by-address route — reach a contact you only know the
+ email of through :meth:`list_v1`'s ``search`` filter.
+ """
+ response: ContactV1 = self._client.request(
+ method="GET", path=f"/api/v1/contacts/{encode_path_segment(id)}"
+ )
+ return response
+
+ def update_v1(self, id: str, body: Body) -> ContactV1:
+ """Patch a contact's ``subscribed`` flag or ``custom_fields``.
+
+ ``email`` is deliberately not patchable: an address is the contact's
+ identity on this surface, and rewriting it in place would change who
+ every earlier send was addressed to. Create the new address instead.
+
+ ``custom_fields`` is **replaced, not merged** — the object you send
+ becomes the whole of it, so read the contact and send back every key you
+ mean to keep. A partial object silently drops the rest.
+ """
+ response: ContactV1 = self._client.request(
+ method="PATCH", path=f"/api/v1/contacts/{encode_path_segment(id)}", body=body
+ )
+ return response
+
+ def delete_v1(self, id: str) -> ContactDeletedV1:
+ """Delete a contact, returning the ``{id, deleted}`` confirmation body.
+
+ Unlike the legacy :meth:`delete`, the acknowledgement is handed back
+ rather than discarded.
+ """
+ response: ContactDeletedV1 = self._client.request(
+ method="DELETE", path=f"/api/v1/contacts/{encode_path_segment(id)}"
+ )
+ return response
+
+ def topic_preferences(self, id: str) -> ContactTopicPreferencesV1:
+ """Read everything this contact has said about what they want.
+
+ The top-level ``subscribed`` is the global marketing opt-out and
+ outranks every topic: false means no marketing reaches them whatever the
+ topic rows say. Each topic's own ``subscribed`` is the effective answer
+ the send path reaches today, with the topic's ``default_opt_in`` already
+ folded in, so a contact who has never answered still reads correctly.
+ """
+ response: ContactTopicPreferencesV1 = self._client.request(
+ method="GET", path=f"/api/v1/contacts/{encode_path_segment(id)}/topics"
+ )
+ return response
diff --git a/src/sendly/resources/deliverability.py b/src/sendly/resources/deliverability.py
new file mode 100644
index 0000000..9f7718e
--- /dev/null
+++ b/src/sendly/resources/deliverability.py
@@ -0,0 +1,109 @@
+"""Deliverability resource (``/api/v1``)."""
+
+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 (
+ DeliverabilityDiagnosisV1,
+ DmarcReportListV1,
+ JSONDict,
+ Query,
+ RecipientDomainStatsListV1,
+ )
+
+
+class DeliverabilityResource:
+ """Why mail from your domains is, or is not, arriving.
+
+ Responses are bare ``/api/v1`` bodies -- no ``{success, data}`` envelope --
+ and errors are RFC 9457 problem documents.
+ """
+
+ def __init__(self, client: Sendly) -> None:
+ self._client = client
+
+ def diagnose(self, query: Query) -> DeliverabilityDiagnosisV1:
+ """Diagnose one of your SENDING domains.
+
+ Answers with its DNS identity, the project's recent delivery outcomes,
+ optionally one recipient's suppression state, and the ``findings`` drawn
+ from them, worst first. Branch on a finding's ``code``, never on its
+ prose.
+
+ ``domain`` is required -- the endpoint answers about one domain. The
+ optional ``address`` is a RECIPIENT to check alongside it, because being
+ suppressed is the single most common reason one person stops receiving
+ mail while everyone else still does. ``window_days`` (1-30, default 7)
+ only moves the delivery counters.
+
+ Nothing here is looked up live: the DNS statuses are the verification
+ refresh job's cached results, and ``identity.last_checked_at`` says when
+ they were filled. ``recent_delivery`` is project-wide rather than
+ per-domain -- its own ``scope`` field says so -- because an email row
+ records no sending domain.
+ """
+ response: DeliverabilityDiagnosisV1 = self._client.request(
+ method="GET", path="/api/v1/deliverability/diagnose", query=query
+ )
+ return response
+
+ def list_domain_stats(self, query: Query | None = None) -> RecipientDomainStatsListV1:
+ """Delivery outcomes by RECIPIENT domain and UTC day, newest day first.
+
+ These are the domains you send TO -- ``gmail.com``, ``outlook.com`` --
+ not the domains you send FROM. That is the axis :meth:`diagnose` cannot
+ report: its project-wide rates hide the case that matters most, one
+ recipient domain refusing nearly everything while the rest of your mail
+ is healthy.
+
+ Cursor-paginated on ``limit`` + ``after``. The counts come from an hourly
+ rollup job over a rolling 30-day window, not from a query run on request;
+ each row's ``computed_at`` says when it was last rebuilt. No rate is
+ published, because a rate over three sends is not information.
+ """
+ response: RecipientDomainStatsListV1 = self._client.request(
+ method="GET", path="/api/v1/deliverability/domains", query=query
+ )
+ return response
+
+ def iter_list_domain_stats(self, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every recipient-domain row across pages, one day-and-domain at a time."""
+ return iterate_cursor(self.list_domain_stats, query)
+
+ def list_dmarc_reports(self, query: Query | None = None) -> DmarcReportListV1:
+ """DMARC aggregate (RUA) reports about your domains, newest window first.
+
+ Cursor-paginated on ``limit`` + ``after``.
+
+ An empty list is the correct answer, not a bug, until a policy domain is
+ registered in this project and its DMARC record names an address we
+ receive: only reports about a registered domain are stored, and receivers
+ send them on their own schedule (typically once a day).
+
+ ``intake_configured`` says which kind of empty you are looking at. When
+ it is ``False`` this deployment has no DMARC report intake mailbox at
+ all, so no report can ever arrive and an empty ``data`` means the
+ feature is off -- not that your domains are clean. The two are otherwise
+ indistinguishable, so read the flag before reporting "no DMARC failures"
+ to anyone.
+
+ ``pass_count`` counts DMARC ALIGNMENT taken from ``policy_evaluated``,
+ not raw authentication results -- a message can pass SPF for a domain
+ that is not the one in its From header, which is exactly the case DMARC
+ exists to catch.
+ """
+ response: DmarcReportListV1 = self._client.request(
+ method="GET", path="/api/v1/deliverability/dmarc", query=query
+ )
+ return response
+
+ def iter_list_dmarc_reports(self, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every DMARC report across pages, one report at a time."""
+ return iterate_cursor(self.list_dmarc_reports, query)
diff --git a/src/sendly/resources/domains.py b/src/sendly/resources/domains.py
index 4d0ed42..9450d27 100644
--- a/src/sendly/resources/domains.py
+++ b/src/sendly/resources/domains.py
@@ -5,20 +5,36 @@
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,
+ DomainDeletedV1,
DomainListResponse,
+ DomainListV1,
DomainRecord,
DomainSetupSession,
+ DomainV1,
DomainVerificationStatus,
+ JSONDict,
+ Query,
)
class DomainsResource:
- """Register and verify sending domains."""
+ """Register and verify sending domains, on both surfaces.
+
+ The unsuffixed methods speak the legacy ``/api/*`` dialect -- camelCase
+ bodies inside a ``{success, data}`` envelope the SDK unwraps. The
+ ``_v1``-suffixed methods speak ``/api/v1``: bare snake_case bodies, cursor
+ pagination, and RFC 9457 problem documents on error. Both answer the same
+ questions, so the suffix is there to keep a call site from confusing one for
+ the other.
+ """
def __init__(self, client: Sendly) -> None:
self._client = client
@@ -28,7 +44,12 @@ def create(self, body: Body) -> DomainRecord:
Pass ``region`` to pin this domain to a specific AWS SES region. On the
first domain for a project this also locks the project's region;
- subsequent calls must match. The response includes DNS records to set.
+ subsequent calls must match.
+
+ The response carries ``dkimTokens`` -- the SES DKIM tokens to publish as
+ CNAME records before the domain can verify -- alongside ``dkimStatus``,
+ ``spfStatus`` and ``dmarcStatus``, each the result of the last DNS check
+ for that record type.
"""
envelope = self._client.request(method="POST", path="/api/domains", body=body)
record: DomainRecord = self._client.unwrap(envelope)
@@ -48,7 +69,14 @@ def get(self, id: str) -> DomainRecord:
return record
def verify(self, id: str) -> DomainVerificationStatus:
- """Trigger SES verification for a domain."""
+ """Trigger SES verification for a domain.
+
+ ``status`` is SES's own raw DKIM verification state (``Success``,
+ ``Pending``), while ``dkimStatus``, ``spfStatus`` and ``dmarcStatus``
+ are this platform's own DNS check per record type. ``tokens`` carries
+ the DKIM tokens SES has still to report and is absent once verification
+ has resolved.
+ """
envelope = self._client.request(
method="POST", path=f"/api/domains/{encode_path_segment(id)}/verify"
)
@@ -78,6 +106,105 @@ def start_setup(self, id: str) -> DomainSetupSession:
session: DomainSetupSession = self._client.unwrap(envelope)
return session
+ def assign_stream(self, id: str, body: Body) -> DomainRecord:
+ """Assign this sending identity to transactional or marketing traffic.
+
+ Streams are enforced, not labelled: once assigned, a send of the other
+ kind from this identity is refused with 403 -- which is what keeps a
+ campaign's complaint rate off the identity your password resets go out
+ on. Pass ``stream: None`` to unassign, returning it to carrying both.
+
+ ``streamDefault`` demotes whichever identity currently holds the default
+ for that stream, and ``defaultFromAddress`` has to be an address on this
+ identity's own host. Every field is optional; an omitted one is left
+ alone.
+
+ Legacy dialect: camelCase body, and the updated domain arrives inside
+ the ``{success, data}`` envelope this method unwraps for you.
+ """
+ envelope = self._client.request(
+ method="PATCH", path=f"/api/domains/{encode_path_segment(id)}", body=body
+ )
+ record: DomainRecord = self._client.unwrap(envelope)
+ return record
+
def delete(self, id: str) -> None:
"""Delete a domain."""
self._client.request(method="DELETE", path=f"/api/domains/{encode_path_segment(id)}")
+
+ def list_v1(self, query: Query | None = None) -> DomainListV1:
+ """List sending domains, newest first.
+
+ Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and
+ answers ``{data, has_more, next_cursor}``. :meth:`iter_list_v1` drives
+ that walk for you.
+
+ ``verified`` is SES's verdict on the identity and is what decides
+ whether mail can leave from this domain; ``dkim_verified`` is a separate
+ fact -- what the DNS health refresh last read for the DKIM records -- so
+ the two disagree while a re-check is in flight and neither is a spelling
+ of the other.
+ """
+ response: DomainListV1 = self._client.request(
+ method="GET", path="/api/v1/domains", query=query
+ )
+ return response
+
+ def iter_list_v1(self, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every sending domain across pages, following the cursor for you."""
+ return iterate_cursor(self.list_v1, query)
+
+ def create_v1(self, body: Body) -> DomainV1:
+ """Register a sending domain and start SES DKIM verification.
+
+ The identity comes back with ``verified`` false -- nothing is verified
+ until the DKIM records are published in the domain's own DNS and SES
+ resolves them, so poll :meth:`verify_v1` after publishing them.
+
+ The first domain a project adds LOCKS the project's SES ``region``;
+ every later domain must match it. ``stream_default`` requires
+ ``stream``, and sending it alone is answered with 422
+ ``validation_error`` rather than ignored.
+ """
+ response: DomainV1 = self._client.request(method="POST", path="/api/v1/domains", body=body)
+ return response
+
+ def get_v1(self, id: str) -> DomainV1:
+ """Fetch a single sending domain by id."""
+ response: DomainV1 = self._client.request(
+ method="GET", path=f"/api/v1/domains/{encode_path_segment(id)}"
+ )
+ return response
+
+ def verify_v1(self, id: str) -> DomainV1:
+ """Re-read the domain's state from SES and DNS, and return it refreshed.
+
+ This does not verify anything and changes none of the domain's own
+ fields. Verification happens in the domain's DNS, when its owner
+ publishes the DKIM records SES minted at creation, and Amazon decides
+ when those resolve. What this call does is ask SES what it currently
+ sees, re-check SPF and DMARC, and persist that answer -- so a caller
+ polling after a DNS change learns the outcome without waiting for the
+ periodic sweep. Calling it on a domain whose records are not published
+ yet is not an error and does not hurry anything.
+
+ A POST rather than a GET because the refreshed state is persisted and a
+ verified/unverified transition notifies the project.
+ """
+ response: DomainV1 = self._client.request(
+ method="POST", path=f"/api/v1/domains/{encode_path_segment(id)}/verify"
+ )
+ return response
+
+ def delete_v1(self, id: str) -> DomainDeletedV1:
+ """Remove a sending domain. Returns the ``{id, deleted}`` confirmation body.
+
+ Refused with 409 ``conflict`` while a template, workflow step or active
+ campaign still sends from an address on this host. The SES identity goes
+ too unless another project holds the same host -- and its DKIM keys with
+ it, so re-adding later mints records that must be published again.
+ """
+ response: DomainDeletedV1 = self._client.request(
+ method="DELETE", path=f"/api/v1/domains/{encode_path_segment(id)}"
+ )
+ return response
diff --git a/src/sendly/resources/emails.py b/src/sendly/resources/emails.py
index e20f6dc..3d3f2b5 100644
--- a/src/sendly/resources/emails.py
+++ b/src/sendly/resources/emails.py
@@ -11,13 +11,13 @@
from sendly.types import (
BatchSendResponse,
Body,
- EmailGetResponse,
+ EmailDetailResponse,
EmailListResponse,
+ EmailResponse,
EmailTestV1,
EmailV1,
Query,
SendEmailData,
- SuccessEmpty,
)
@@ -105,16 +105,28 @@ def list(self, query: Query | None = None) -> EmailListResponse:
)
return response
- def get(self, id: str) -> EmailGetResponse:
- """Fetch a single email and its delivery events."""
- response: EmailGetResponse = self._client.request(
+ def get(self, id: str) -> EmailDetailResponse:
+ """Fetch a single email together with its DELIVERY history, oldest first.
+
+ ``events`` here is the delivery timeline behind ``status`` -- not the
+ custom events recorded with ``events.record``, which are read from
+ ``events.list``. Before 1.1 this operation answered the wrong relation
+ and published the message's dedup and idempotency ledger keys with it.
+ """
+ response: EmailDetailResponse = self._client.request(
method="GET", path=f"/api/emails/{encode_path_segment(id)}"
)
return response
- def cancel_schedule(self, id: str) -> SuccessEmpty:
- """Cancel a scheduled (PENDING) email before it fires."""
- response: SuccessEmpty = self._client.request(
+ def cancel_schedule(self, id: str) -> EmailResponse:
+ """Cancel a scheduled (PENDING) email before it fires.
+
+ Answers the email itself, not an empty acknowledgement: the contract has
+ always published that shape here, and the caller wants the row's new
+ status more than a success flag it already inferred from the absence of
+ an exception.
+ """
+ response: EmailResponse = self._client.request(
method="DELETE", path=f"/api/emails/{encode_path_segment(id)}/schedule"
)
return response
diff --git a/src/sendly/resources/events.py b/src/sendly/resources/events.py
index 18a3f40..692c746 100644
--- a/src/sendly/resources/events.py
+++ b/src/sendly/resources/events.py
@@ -49,7 +49,9 @@ def track(self, body: Body) -> TrackEventData:
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.
+ Requires ``name``; optionally takes ``contact_id`` and a ``payload``
+ object. It was ``data`` before 1.1 -- on a wire where every legacy
+ envelope has a ``data``, the name said nothing about whose it was.
The v1 counterpart of :meth:`track`, returning the created event body
rather than a ``{success, data}`` envelope.
diff --git a/src/sendly/resources/lists.py b/src/sendly/resources/lists.py
index 4a1b621..3885ed2 100644
--- a/src/sendly/resources/lists.py
+++ b/src/sendly/resources/lists.py
@@ -5,17 +5,36 @@
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, ListSubscribeData, ListUnsubscribeData
+ from sendly.types import (
+ Body,
+ EmailValidationRunV1,
+ JSONDict,
+ ListDeletedV1,
+ ListListV1,
+ ListSubscribeData,
+ ListUnsubscribeData,
+ ListV1,
+ Query,
+ )
class ListsResource:
- """Manage a contact's membership on a subscriber list.
+ """Subscriber lists, on both surfaces.
- Both calls accept sending-only (``pk_*``) keys so they can back a public
- subscribe or preference form directly.
+ :meth:`subscribe` and :meth:`unsubscribe` manage one contact's membership
+ over the legacy ``/api/*`` dialect (camelCase inside a ``{success, data}``
+ envelope the SDK unwraps) and accept sending-only (``pk_*``) keys, so they
+ can back a public subscribe or preference form directly. The
+ ``_v1``-suffixed methods manage the lists themselves over ``/api/v1``: bare
+ snake_case bodies and RFC 9457 problem documents. Both dialects describe the
+ same lists, so the suffix is there to keep a call site from confusing one for
+ the other.
"""
def __init__(self, client: Sendly) -> None:
@@ -29,8 +48,9 @@ def subscribe(self, id: str, body: Body) -> ListSubscribeData:
* 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.
+ the confirmation email — deliver
+ ``/api/lists/confirm-subscription?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
@@ -57,3 +77,76 @@ def unsubscribe(self, id: str, body: Body) -> ListUnsubscribeData:
)
data: ListUnsubscribeData = self._client.unwrap(envelope)
return data
+
+ def list_v1(self, query: Query | None = None) -> ListListV1:
+ """List the project's subscriber lists on the ``/api/v1`` surface.
+
+ Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and
+ answers ``{data, has_more, next_cursor}``. Hold the arguments 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: ListListV1 = self._client.request(method="GET", path="/api/v1/lists", query=query)
+ return response
+
+ def iter_list_v1(self, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every list across pages, following the cursor for you."""
+ return iterate_cursor(self.list_v1, query)
+
+ def create_v1(self, body: Body) -> ListV1:
+ """Create a list. Requires ``name``; ``double_opt_in`` defaults to false.
+
+ Turning double opt-in on does not make Sendly send anything — it only
+ changes :meth:`subscribe` to create the membership ``PENDING`` and hand
+ back the ``confirmToken`` your application delivers.
+ """
+ response: ListV1 = self._client.request(method="POST", path="/api/v1/lists", body=body)
+ return response
+
+ def get_v1(self, id: str) -> ListV1:
+ """Fetch a single list.
+
+ ``member_count`` counts memberships in *any* status, ``PENDING`` and
+ ``UNSUBSCRIBED`` included, so it is not the size of the audience a
+ campaign would reach.
+ """
+ response: ListV1 = self._client.request(
+ method="GET", path=f"/api/v1/lists/{encode_path_segment(id)}"
+ )
+ return response
+
+ def update_v1(self, id: str, body: Body) -> ListV1:
+ """Patch a list's name, description, opt-in mode, confirmation template,
+ or redirect URL.
+
+ Only the fields you send are changed; ``member_count`` is derived and
+ never accepted here.
+ """
+ response: ListV1 = self._client.request(
+ method="PATCH", path=f"/api/v1/lists/{encode_path_segment(id)}", body=body
+ )
+ return response
+
+ def delete_v1(self, id: str) -> ListDeletedV1:
+ """Delete a list, returning the ``{id, deleted}`` confirmation body.
+
+ Removes the list, not the contacts on it.
+ """
+ response: ListDeletedV1 = self._client.request(
+ method="DELETE", path=f"/api/v1/lists/{encode_path_segment(id)}"
+ )
+ return response
+
+ def start_validation_run(self, id: str) -> EmailValidationRunV1:
+ """Start a bulk address-validation run over the list's members.
+
+ **Billed per address checked**, so starting a run over a large list
+ costs real money every time — it is not a free refresh. Answers 202 with
+ the run in ``pending``; read its progress and counts back with
+ ``validation.get_run``.
+ """
+ response: EmailValidationRunV1 = self._client.request(
+ method="POST", path=f"/api/v1/lists/{encode_path_segment(id)}/validation-runs"
+ )
+ return response
diff --git a/src/sendly/resources/mailboxes.py b/src/sendly/resources/mailboxes.py
index f09253a..e89578b 100644
--- a/src/sendly/resources/mailboxes.py
+++ b/src/sendly/resources/mailboxes.py
@@ -8,22 +8,25 @@
if TYPE_CHECKING:
from sendly.client import Sendly
- from sendly.types import AppPasswordList, MailboxDetail, MailboxList
+ from sendly.types import AppPasswordList, Body, JSONDict, MailboxDetail, MailboxList
class MailboxesResource:
- """Receiving mailboxes on the project's verified domains.
-
- Read only, and deliberately so. Creating or deleting a mailbox, and minting
- or revoking an app password, all resolve the acting project admin from the
- session user. An API-key context carries no user, so those routes answer
- 401 to any key however broad its scopes -- the contract records this by
- publishing ``SessionAuth`` without ``ApiKeyAuth`` on them. This SDK
- authenticates only with API keys, so such methods could never succeed; they
- are listed in ``tests/test_contract.py``'s ``NOT_SDK_CALLABLE`` instead.
-
- The three reads below are the opposite case: their membership check is
- conditional, so a key really can call them.
+ """Receiving mailboxes on the project's verified domains, plus the two
+ composition operations an API key may drive.
+
+ Mailbox *lifecycle* is what stays out of reach. Creating or deleting a
+ mailbox, and minting or revoking an app password, all resolve the acting
+ project admin from the session user. An API-key context carries no user, so
+ those routes answer 401 to any key however broad its scopes -- the contract
+ records this by publishing ``SessionAuth`` without ``ApiKeyAuth`` on them.
+ This SDK authenticates only with API keys, so such methods could never
+ succeed; they are listed in ``tests/test_contract.py``'s
+ ``NOT_SDK_CALLABLE`` instead.
+
+ Everything below is the opposite case: the reads' membership check is
+ conditional, and :meth:`send_message` / :meth:`draft_message` publish
+ ``ApiKeyAuth`` outright, so a key really can call them.
"""
def __init__(self, client: Sendly) -> None:
@@ -73,3 +76,58 @@ def list_app_passwords(self, id: str) -> AppPasswordList:
)
records: AppPasswordList = self._client.unwrap(envelope)
return records
+
+ def send_message(self, id: str, body: Body) -> JSONDict:
+ """SENDS a new message -- real mail leaves the account, from the mailbox
+ in the path, over its own domain, and the recipient can reply to it.
+
+ There is no ``from`` field, on purpose: a route that sends under a
+ customer's own identity must not take that identity as an argument.
+ ``body`` is plain text and HTML is refused -- Sendly renders the HTML
+ part itself, escaping as it goes, so text becomes markup in exactly one
+ place.
+
+ Bcc recipients are delivered to but appear in no header, so the copy
+ filed in the mailbox's Sent folder does not record them. The message is
+ stored as a new conversation, and the reply threads onto it.
+
+ Refusals worth handling by name: 422 ``RECIPIENT_SUPPRESSED`` (a
+ recipient is on the project's suppression list), 422
+ ``CONTENT_REFUSED`` (the outbound scanner declined it), 503
+ ``CONTENT_SCAN_UNAVAILABLE`` (no verdict yet for a young project --
+ nothing was sent, retry shortly). A mailbox may send 60 messages an
+ hour here.
+ """
+ envelope = self._client.request(
+ method="POST",
+ path=f"/api/mailboxes/{encode_path_segment(id)}/messages",
+ body=body,
+ )
+ submitted: JSONDict = self._client.unwrap(envelope)
+ return submitted
+
+ def draft_message(self, id: str, body: Body) -> JSONDict:
+ """SENDS NOTHING -- asks Sendly's assistant to write text for this
+ mailbox and hands it back for you to review.
+
+ The response always reports ``sent: False``, and no argument changes
+ that. ``mode`` picks the job: ``draft`` writes a new email from a brief,
+ ``rewrite`` reworks text you already have, ``subject`` returns
+ alternative subject lines in ``subjects``. The mailbox is named only so
+ the text can be written in that address's voice; no correspondence is
+ read and nothing is stored.
+
+ That is why this asks only for ``mailboxes:read`` while
+ :meth:`send_message` needs ``mailboxes:send`` -- a client that may draft
+ is not thereby a client that may mail your customers. Everything you
+ pass is treated strictly as data describing what to write, never as
+ instructions to the model. Capped at 120 requests an hour per project;
+ 502 means the model was unreachable.
+ """
+ envelope = self._client.request(
+ method="POST",
+ path=f"/api/mailboxes/{encode_path_segment(id)}/drafts",
+ body=body,
+ )
+ draft: JSONDict = self._client.unwrap(envelope)
+ return draft
diff --git a/src/sendly/resources/snippets.py b/src/sendly/resources/snippets.py
new file mode 100644
index 0000000..7f4b438
--- /dev/null
+++ b/src/sendly/resources/snippets.py
@@ -0,0 +1,72 @@
+"""Snippets resource."""
+
+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,
+ Query,
+ SnippetListResponse,
+ SnippetRecord,
+ )
+
+
+class SnippetsResource:
+ """Reusable body fragments a template pulls in with ``{{> name}}``.
+
+ Legacy dialect: ``{success, data}`` envelopes and camelCase fields. Gated by
+ the same ``templates:*`` scopes as the templates that include them, because
+ a snippet is part of a template body rather than a resource with an audience
+ of its own.
+ """
+
+ def __init__(self, client: Sendly) -> None:
+ self._client = client
+
+ def create(self, body: Body) -> SnippetRecord:
+ """Create a snippet. Requires ``name`` and ``body``.
+
+ ``name`` is the literal identifier templates include with ``{{> name}}``
+ and is unique within the project, so a clash raises
+ :class:`SendlyConflictError`.
+ """
+ envelope = self._client.request(method="POST", path="/api/snippets", body=body)
+ record: SnippetRecord = self._client.unwrap(envelope)
+ return record
+
+ def list(self, query: Query | None = None) -> SnippetListResponse:
+ """List snippets with cursor pagination (``limit``/``cursor``) + optional
+ ``search`` over name and description."""
+ response: SnippetListResponse = self._client.request(
+ method="GET", path="/api/snippets", query=query
+ )
+ return response
+
+ def get(self, id: str) -> SnippetRecord:
+ """Fetch a single snippet by id."""
+ envelope = self._client.request(
+ method="GET", path=f"/api/snippets/{encode_path_segment(id)}"
+ )
+ record: SnippetRecord = self._client.unwrap(envelope)
+ return record
+
+ def update(self, id: str, body: Body) -> SnippetRecord:
+ """Patch an existing snippet."""
+ envelope = self._client.request(
+ method="PATCH", path=f"/api/snippets/{encode_path_segment(id)}", body=body
+ )
+ record: SnippetRecord = self._client.unwrap(envelope)
+ return record
+
+ def delete(self, id: str) -> None:
+ """Delete a snippet. Returns ``None`` (the API responds 200 with the
+ deleted snippet's id). Templates that still include it keep rendering --
+ an absent snippet renders as an empty string, like an absent variable."""
+ self._client.request(
+ method="DELETE", path=f"/api/snippets/{encode_path_segment(id)}", no_content=True
+ )
diff --git a/src/sendly/resources/suppression.py b/src/sendly/resources/suppression.py
index a8aa138..be36b77 100644
--- a/src/sendly/resources/suppression.py
+++ b/src/sendly/resources/suppression.py
@@ -5,20 +5,34 @@
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,
SuppressionCheckResponse,
+ SuppressionDeletedV1,
SuppressionListResponse,
+ SuppressionListV1,
SuppressionRecord,
+ SuppressionV1,
)
class SuppressionResource:
- """Manage the project suppression list."""
+ """Manage the project suppression list — the addresses no send may reach.
+
+ The unsuffixed methods speak legacy ``/api/suppression`` (singular path,
+ ``{success, data}`` envelopes); the ``_v1`` methods speak
+ ``/api/v1/suppressions`` (plural path, bare bodies, RFC 9457 problem
+ documents). Both answer the same question, so the suffix is what stops a
+ call site from reaching for one and reading the other's shape.
+ """
def __init__(self, client: Sendly) -> None:
self._client = client
@@ -30,7 +44,13 @@ def add(self, body: Body) -> SuppressionRecord:
return record
def list(self, query: Query | None = None) -> SuppressionListResponse:
- """List suppressions with optional reason filter + cursor pagination."""
+ """List suppressions with optional reason filter + cursor pagination.
+
+ Alone among the legacy reads, this route answers no ``{success, data}``
+ envelope: the page IS the body, ``{items, nextCursor}``, so nothing is
+ unwrapped. Each record carries ``scope`` -- ``PROJECT`` for every record
+ this API creates or returns today.
+ """
response: SuppressionListResponse = self._client.request(
method="GET", path="/api/suppression", query=query
)
@@ -50,3 +70,62 @@ def remove(self, email: str) -> None:
path=f"/api/suppression/{encode_path_segment(email)}",
no_content=True,
)
+
+ def list_v1(self, query: Query | None = None) -> SuppressionListV1:
+ """List suppressed addresses, newest first.
+
+ Accepts ``limit`` (1-100, default 20), ``after`` (opaque cursor) and
+ ``reason``, and answers ``{data, has_more, next_cursor}``. Hold
+ ``reason`` steady across one walk: changing it mid-pagination
+ invalidates the cursor and the API answers 422 ``validation_error``
+ telling you to restart from the first page.
+ """
+ response: SuppressionListV1 = self._client.request(
+ method="GET", path="/api/v1/suppressions", query=query
+ )
+ return response
+
+ def iter_list_v1(self, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every suppressed address across pages, following the cursor for you."""
+ return iterate_cursor(self.list_v1, query)
+
+ def create_v1(self, body: Body) -> SuppressionV1:
+ """Suppress an address, so no further send reaches it.
+
+ Idempotent: an already-suppressed address answers 201 with the existing
+ record, and the first ``reason`` wins — a later manual entry must not
+ overwrite what an SES bounce recorded. ``source`` is not accepted in the
+ body; it is derived from the credential, so a record's provenance cannot
+ be dressed up as a deliverability fact.
+ """
+ response: SuppressionV1 = self._client.request(
+ method="POST", path="/api/v1/suppressions", body=body
+ )
+ return response
+
+ def get_v1(self, email: str) -> SuppressionV1:
+ """Fetch the suppression record for one address.
+
+ The answer is definite either way: 200 means suppressed and says why,
+ 404 ``resource_not_found`` means the address is not on the list. A 200
+ may also come from a platform-wide block recorded outside this project.
+ """
+ response: SuppressionV1 = self._client.request(
+ method="GET", path=f"/api/v1/suppressions/{encode_path_segment(email)}"
+ )
+ return response
+
+ def delete_v1(self, email: str) -> SuppressionDeletedV1:
+ """Un-suppress an address: mail can flow to it again. Returns the
+ ``{email, deleted}`` confirmation body.
+
+ This is the one call on this surface that can put mail back into an
+ inbox that asked you to stop. It does NOT clear AWS SES's own
+ account-level suppression list, so an address SES suppressed after a
+ hard bounce stays undeliverable through SES even once this record is
+ gone. Idempotent: an address that was never suppressed answers 200 too.
+ """
+ response: SuppressionDeletedV1 = self._client.request(
+ method="DELETE", path=f"/api/v1/suppressions/{encode_path_segment(email)}"
+ )
+ return response
diff --git a/src/sendly/resources/templates.py b/src/sendly/resources/templates.py
index 18f66e6..598f313 100644
--- a/src/sendly/resources/templates.py
+++ b/src/sendly/resources/templates.py
@@ -5,19 +5,33 @@
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,
+ TemplateDeletedV1,
TemplateListResponse,
+ TemplateListV1,
TemplateRecord,
+ TemplateV1,
)
class TemplatesResource:
- """Create and manage reusable email templates."""
+ """Create and manage reusable email templates, in both dialects.
+
+ The unsuffixed methods speak legacy ``/api/templates`` — ``{success, data}``
+ envelopes and camelCase fields. The ``_v1`` methods speak
+ ``/api/v1/templates`` — bare bodies, snake_case fields and RFC 9457 problem
+ documents. Both answer the same question, so the suffix is what stops a call
+ site from reaching for one and reading the other's shape.
+ """
def __init__(self, client: Sendly) -> None:
self._client = client
@@ -30,7 +44,7 @@ def create(self, body: Body) -> TemplateRecord:
def list(self, query: Query | None = None) -> TemplateListResponse:
"""List templates with cursor pagination (``limit``/``cursor``) + optional
- type filter."""
+ ``emailCategory`` filter."""
response: TemplateListResponse = self._client.request(
method="GET", path="/api/templates", query=query
)
@@ -45,7 +59,13 @@ def get(self, id: str) -> TemplateRecord:
return record
def update(self, id: str, body: Body) -> TemplateRecord:
- """Patch an existing template."""
+ """Patch an existing template.
+
+ An update that changes the rendered content increments
+ ``currentVersion``; one that only renames leaves it alone. A campaign
+ records the version it sent, so comparing the two is how a caller tells
+ "the template changed since" from "the template was renamed".
+ """
envelope = self._client.request(
method="PATCH", path=f"/api/templates/{encode_path_segment(id)}", body=body
)
@@ -59,3 +79,69 @@ def delete(self, id: str) -> None:
self._client.request(
method="DELETE", path=f"/api/templates/{encode_path_segment(id)}", no_content=True
)
+
+ def list_v1(self, query: Query | None = None) -> TemplateListV1:
+ """List templates, newest first.
+
+ Accepts ``limit`` (1-100, default 20), ``after`` (opaque cursor),
+ ``search`` and ``email_category``, and answers
+ ``{data, has_more, next_cursor}``. ``search`` matches the NAME only —
+ narrower than the dashboard's search, which also reads description and
+ subject. 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: TemplateListV1 = self._client.request(
+ method="GET", path="/api/v1/templates", query=query
+ )
+ return response
+
+ def iter_list_v1(self, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every template across pages, following the cursor for you."""
+ return iterate_cursor(self.list_v1, query)
+
+ def create_v1(self, body: Body) -> TemplateV1:
+ """Create a template. ``email_category`` defaults to ``MARKETING``.
+
+ The ``from`` domain must already be a verified sending identity — an
+ unverified sender is refused with 403 ``forbidden`` here rather than
+ becoming a campaign that fails at send time.
+ """
+ response: TemplateV1 = self._client.request(
+ method="POST", path="/api/v1/templates", body=body
+ )
+ return response
+
+ def get_v1(self, id: str) -> TemplateV1:
+ """Fetch a single template by id."""
+ response: TemplateV1 = self._client.request(
+ method="GET", path=f"/api/v1/templates/{encode_path_segment(id)}"
+ )
+ return response
+
+ def update_v1(self, id: str, body: Body) -> TemplateV1:
+ """Patch a template. Omitted fields are left alone.
+
+ Touching ``subject``, ``body``, ``from``, ``from_name`` or ``reply_to``
+ snapshots the previous content into version history and increments
+ ``version``; touching only ``name``, ``description`` or
+ ``email_category`` does not, because neither is content a send would
+ have rendered.
+ """
+ response: TemplateV1 = self._client.request(
+ method="PATCH", path=f"/api/v1/templates/{encode_path_segment(id)}", body=body
+ )
+ return response
+
+ def delete_v1(self, id: str) -> TemplateDeletedV1:
+ """Delete a template. Returns the ``{id, deleted}`` confirmation body —
+ the legacy :meth:`delete` discards it, this one hands it back.
+
+ A template a workflow step or an active campaign (DRAFT, SCHEDULED or
+ SENDING) still points at is refused with 409 ``conflict``. Emails
+ already sent from it are not erased.
+ """
+ response: TemplateDeletedV1 = self._client.request(
+ method="DELETE", path=f"/api/v1/templates/{encode_path_segment(id)}"
+ )
+ return response
diff --git a/src/sendly/resources/topics.py b/src/sendly/resources/topics.py
new file mode 100644
index 0000000..eac88b7
--- /dev/null
+++ b/src/sendly/resources/topics.py
@@ -0,0 +1,109 @@
+"""Topics 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,
+ TopicListV1,
+ TopicSubscriptionV1,
+ TopicV1,
+ )
+
+
+class TopicsResource:
+ """The consent vocabulary a project mails against.
+
+ A contact subscribes to a topic rather than to a campaign, so switching one
+ off silences a whole audience. Responses are bare v1 bodies (no
+ ``{success, data}`` envelope) and errors are RFC 9457 problem documents.
+ """
+
+ def __init__(self, client: Sendly) -> None:
+ self._client = client
+
+ def list(self, query: Query | None = None) -> TopicListV1:
+ """List topics, newest first.
+
+ Accepts ``limit`` (1-100), ``after`` and ``include_archived``, the
+ same pagination parameters as every other v1 list. Archived topics are
+ omitted unless you ask for them; there is no delete, because a topic is
+ where people's answers are recorded.
+ """
+ response: TopicListV1 = self._client.request(
+ method="GET", path="/api/v1/topics", query=query
+ )
+ return response
+
+ def iter_list(self, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every topic across pages, following the cursor for you.
+
+ This was written out through 1.0, because the endpoint named its cursor
+ ``cursor`` on both sides where every other v1 list takes ``after`` and
+ answers ``next_cursor`` -- so the shared walker would have sent a
+ parameter the route ignored and read a field it never returned. The
+ route speaks the one dialect now.
+ """
+ return iterate_cursor(self.list, query)
+
+ def create(self, body: Body) -> TopicV1:
+ """Create a topic. Requires ``key`` and ``name``.
+
+ ``key`` is the stable name every preference form and integration refers
+ to, so it survives a rename of ``name`` and cannot be changed later.
+
+ ``default_opt_in`` decides what silence means for a contact who never
+ answers: true for a topic introduced over a list that already consented
+ to hear from you, false for anything a person has to ask for.
+ """
+ response: TopicV1 = self._client.request(method="POST", path="/api/v1/topics", body=body)
+ return response
+
+ def get(self, id: str) -> TopicV1:
+ """Fetch a single topic, including its subscribed and unsubscribed counts."""
+ response: TopicV1 = self._client.request(
+ method="GET", path=f"/api/v1/topics/{encode_path_segment(id)}"
+ )
+ return response
+
+ def update(self, id: str, body: Body) -> TopicV1:
+ """Patch a topic's name, description, ``default_opt_in``, or archived flag.
+
+ ``key`` is not patchable, and ``archived: True`` stands in for the
+ delete that does not exist: it drops the topic from the preference
+ centre and from new sends while every opt-out recorded against it
+ survives.
+ """
+ response: TopicV1 = self._client.request(
+ method="PATCH", path=f"/api/v1/topics/{encode_path_segment(id)}", body=body
+ )
+ return response
+
+ def set_subscription(self, id: str, body: Body) -> TopicSubscriptionV1:
+ """Record what one contact wants on one topic. The two directions differ.
+
+ ``subscribed: True`` does NOT subscribe anybody: it parks the contact at
+ ``pending`` and answers a ``confirmation_url``, and nothing is mailed on
+ this topic until someone opens that link. There is no parameter to skip
+ it -- a caller asserting a subscription is not evidence the mailbox
+ holder agreed. Sendly does not send the confirmation email; you do, from
+ your own verified domain.
+
+ ``subscribed: False`` records the opt-out immediately.
+ """
+ response: TopicSubscriptionV1 = self._client.request(
+ method="POST",
+ path=f"/api/v1/topics/{encode_path_segment(id)}/subscriptions",
+ body=body,
+ )
+ return response
diff --git a/src/sendly/resources/validation.py b/src/sendly/resources/validation.py
new file mode 100644
index 0000000..fa4d238
--- /dev/null
+++ b/src/sendly/resources/validation.py
@@ -0,0 +1,99 @@
+"""Email validation 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,
+ EmailValidationBatchV1,
+ EmailValidationResultListV1,
+ EmailValidationRunV1,
+ JSONDict,
+ Query,
+ )
+
+
+class ValidationResource:
+ """Check addresses before you mail them, and read back what a bulk run found.
+
+ Responses are bare ``/api/v1`` bodies -- no ``{success, data}`` envelope --
+ and errors are RFC 9457 problem documents.
+ """
+
+ def __init__(self, client: Sendly) -> None:
+ self._client = client
+
+ def validate_emails(self, body: Body) -> EmailValidationBatchV1:
+ """Check a batch of addresses. **This is billed per address checked.**
+
+ Every entry in ``emails`` costs money, so looping this over a contact
+ list is looping over your invoice. Validate a whole list with the
+ background run (``client.lists.start_validation_run``) instead of paging
+ it through here.
+
+ At most 50 addresses per call. That ceiling is a latency bound, not a
+ payload one: every distinct domain in the batch costs a DNS round trip.
+
+ Branch on each result's ``verdict``, never on the flags -- ``is_personal``
+ (Gmail, Outlook) and ``is_role_address`` (``support@``) describe ordinary,
+ deliverable addresses that real customers use. A verdict of ``unknown``
+ means DNS did not answer in time, so that address was NOT checked; it is
+ a separate value from ``undeliverable`` on purpose, and deleting a contact
+ on ``unknown`` deletes a live one over a network hiccup.
+ """
+ response: EmailValidationBatchV1 = self._client.request(
+ method="POST", path="/api/v1/email-validations", body=body
+ )
+ return response
+
+ def get_run(self, id: str) -> EmailValidationRunV1:
+ """Fetch a bulk validation run: how far it has got, and what it found.
+
+ The other way a run starts is ``client.lists.start_validation_run``,
+ which validates every address on a list in the background and answers
+ with the run this method polls. A run is finished when ``status`` is
+ ``completed`` or ``failed`` -- never when a percentage reaches 100,
+ because there is deliberately no total to divide by: a list changes size
+ while a run walks it.
+ """
+ response: EmailValidationRunV1 = self._client.request(
+ method="GET", path=f"/api/v1/validation-runs/{encode_path_segment(id)}"
+ )
+ return response
+
+ def list_results(self, id: str, query: Query | None = None) -> EmailValidationResultListV1:
+ """List one page of a run's verdicts.
+
+ Filter with ``verdict`` -- ``undeliverable`` is the page to read before
+ acting on a run, and ``unknown`` is the one never to act on, since those
+ addresses were not actually checked.
+
+ Pages on ``after`` and answers ``next_cursor``, like every other v1
+ collection. :meth:`iter_list_results` drives that loop for you.
+ """
+ response: EmailValidationResultListV1 = self._client.request(
+ method="GET",
+ path=f"/api/v1/validation-runs/{encode_path_segment(id)}/results",
+ query=query,
+ )
+ return response
+
+ def iter_list_results(self, id: str, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every result across pages, one address's verdict at a time.
+
+ This was hand-rolled through 1.0, because the endpoint spoke
+ ``cursor`` on both sides while
+ :func:`~sendly.resources._pagination.iterate_cursor` sends ``after``
+ and reads ``next_cursor`` -- so routing it through the helper would have
+ sent an ignored parameter and re-fetched page one forever. The route
+ speaks the one dialect now.
+ """
+ return iterate_cursor(lambda params: self.list_results(id, params), query)
diff --git a/src/sendly/resources/webhooks.py b/src/sendly/resources/webhooks.py
index bf00a01..04b3924 100644
--- a/src/sendly/resources/webhooks.py
+++ b/src/sendly/resources/webhooks.py
@@ -5,23 +5,40 @@
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,
WebhookCallsListResponse,
+ WebhookCreatedV1,
WebhookCreateResponse,
+ WebhookDeletedV1,
WebhookGetResponse,
WebhookListResponse,
+ WebhookListV1,
WebhookRecord,
WebhookRotateSecretResponse,
+ WebhookSecretRotatedV1,
+ WebhookV1,
)
class WebhooksResource:
- """Manage outbound webhook subscriptions and inspect deliveries."""
+ """Manage outbound webhook subscriptions and inspect deliveries.
+
+ The unsuffixed methods speak the legacy ``/api/*`` dialect -- camelCase
+ bodies inside a ``{success, data}`` envelope the SDK unwraps. The
+ ``_v1``-suffixed methods speak ``/api/v1``: bare snake_case bodies, cursor
+ pagination, and RFC 9457 problem documents on error. Both answer the same
+ questions, so the suffix is there to keep a call site from confusing one for
+ the other.
+ """
def __init__(self, client: Sendly) -> None:
self._client = client
@@ -31,6 +48,11 @@ def create(self, body: Body) -> WebhookCreateResponse:
The response includes the signing secret — store it now, it is only
returned in full at creation and rotation time.
+
+ ``data`` holds the two separately: ``data["webhook"]`` is the endpoint
+ and ``data["secret"]`` is the plaintext. The endpoint's own fields are
+ NOT spread alongside the secret, so the id is
+ ``data["webhook"]["id"]``.
"""
response: WebhookCreateResponse = self._client.request(
method="POST", path="/api/webhooks", body=body
@@ -74,3 +96,93 @@ def list_calls(self, id: str, query: Query | None = None) -> WebhookCallsListRes
method="GET", path=f"/api/webhooks/{encode_path_segment(id)}/calls", query=query
)
return response
+
+ def list_v1(self, query: Query | None = None) -> WebhookListV1:
+ """List webhook endpoints, newest first.
+
+ Accepts ``limit`` (1-100, default 20) and ``after`` (opaque cursor), and
+ answers ``{data, has_more, next_cursor}``. :meth:`iter_list_v1` drives
+ that walk for you. Signing secrets are not on this response -- see
+ :meth:`rotate_secret_v1` if you have lost one.
+ """
+ response: WebhookListV1 = self._client.request(
+ method="GET", path="/api/v1/webhooks", query=query
+ )
+ return response
+
+ def iter_list_v1(self, query: Query | None = None) -> Iterator[JSONDict]:
+ """Iterate every webhook endpoint across pages, following the cursor for you."""
+ return iterate_cursor(self.list_v1, query)
+
+ def create_v1(self, body: Body) -> WebhookCreatedV1:
+ """Register an endpoint to receive HMAC-signed deliveries.
+
+ Requires ``url`` and a non-empty ``event_types``. Returns
+ ``{webhook, secret}``, and this is one of only two calls that ever carry
+ the signing secret -- :meth:`rotate_secret_v1` is the other. It is shown
+ exactly once: no read endpoint returns it, so store it now, because a
+ secret you lose is replaced by rotating rather than recovered. Feed it
+ to :func:`sendly.verify_signature` to authenticate the deliveries that
+ arrive at your endpoint.
+ """
+ response: WebhookCreatedV1 = self._client.request(
+ method="POST", path="/api/v1/webhooks", body=body
+ )
+ return response
+
+ def get_v1(self, id: str) -> WebhookV1:
+ """Fetch one webhook endpoint. The signing secret is not on this response."""
+ response: WebhookV1 = self._client.request(
+ method="GET", path=f"/api/v1/webhooks/{encode_path_segment(id)}"
+ )
+ return response
+
+ def update_v1(self, id: str, body: Body) -> WebhookV1:
+ """Patch a webhook endpoint. Omitted fields are left alone.
+
+ ``event_types`` REPLACES the stored subscription list rather than
+ merging into it, so an event you omit is unsubscribed. Setting
+ ``status`` back to ``ACTIVE`` from ``DISABLED`` also clears the
+ consecutive-failure counter, so an auto-disabled endpoint gets a clean
+ slate. The signing secret is untouched by an update, and is not on this
+ response.
+ """
+ response: WebhookV1 = self._client.request(
+ method="PATCH", path=f"/api/v1/webhooks/{encode_path_segment(id)}", body=body
+ )
+ return response
+
+ def delete_v1(self, id: str) -> WebhookDeletedV1:
+ """Delete a webhook endpoint, and its delivery history with it.
+
+ A delivery attempt is a fact about this endpoint and means nothing once
+ the endpoint is gone. Returns the ``{id, deleted}`` confirmation body.
+ Deliveries already in flight are not recalled, so the endpoint may still
+ receive an event shortly after this returns.
+ """
+ response: WebhookDeletedV1 = self._client.request(
+ method="DELETE", path=f"/api/v1/webhooks/{encode_path_segment(id)}"
+ )
+ return response
+
+ def rotate_secret_v1(self, id: str) -> WebhookSecretRotatedV1:
+ """Mint a fresh signing secret for an endpoint.
+
+ The new plaintext is returned exactly once, here -- this and
+ :meth:`create_v1` are the only two responses that ever carry the secret,
+ and no read endpoint hands it back, so store it now and give it to
+ :func:`sendly.verify_signature`. A secret you lose is replaced by
+ rotating again rather than recovered.
+
+ The outgoing secret is not cut off at once: it keeps verifying until
+ ``previous_secret_expires_at``, and every delivery inside that window
+ carries BOTH signatures, so a verifier can be redeployed without
+ dropping an event. Past that moment the old secret starts being rejected
+ -- as does the older of two secrets if you rotate twice inside the
+ window, because only one previous secret is ever live. ``url``,
+ ``event_types`` and ``status`` are unchanged.
+ """
+ response: WebhookSecretRotatedV1 = self._client.request(
+ method="POST", path=f"/api/v1/webhooks/{encode_path_segment(id)}/rotate-secret"
+ )
+ return response
diff --git a/src/sendly/resources/workflows.py b/src/sendly/resources/workflows.py
index d78f017..eb2b14e 100644
--- a/src/sendly/resources/workflows.py
+++ b/src/sendly/resources/workflows.py
@@ -18,8 +18,10 @@
WorkflowDeleted,
WorkflowExecutionList,
WorkflowExecutionRecord,
+ WorkflowGraphV1,
WorkflowList,
WorkflowRecord,
+ WorkflowStateChangeV1,
WorkflowStats,
)
@@ -137,3 +139,95 @@ def stats(self, id: str, query: Query | None = None) -> WorkflowStats:
query=query,
)
return response
+
+ def get_graph(self, id: str) -> WorkflowGraphV1:
+ """Every step in the workflow, its ``TRIGGER`` entry node included, plus
+ the directed transitions between them.
+
+ A step's ``config`` comes back exactly as stored, camelCase keys and
+ all, rather than projected into the snake_case used elsewhere on v1: the
+ same document is authored by the visual editor, and renaming its keys on
+ the way out would silently drop any key this API does not know on the
+ way back in.
+
+ ``version`` is the workflow's version at the time of the read, so a
+ different number on a later read means somebody edited the graph in
+ between. This body is accepted verbatim by :meth:`replace_graph` -- read
+ a graph, change one step, send it back.
+ """
+ response: WorkflowGraphV1 = self._client.request(
+ method="GET", path=f"/api/v1/workflows/{encode_path_segment(id)}/graph"
+ )
+ return response
+
+ def replace_graph(self, id: str, body: Body) -> WorkflowGraphV1:
+ """Replace the whole graph in one transaction.
+
+ A ``PUT`` and not a ``PATCH``, and that is the point: a graph is nodes
+ *plus* the edges between them, so a partial edit to a step list has no
+ meaning without the transitions that reference it -- half-applied, it
+ would leave steps pointing at steps that no longer exist.
+
+ Ids decide the outcome per step: one you send is kept and updated in
+ place, a fresh uuid creates a step, and an id you omit deletes that step
+ *and its run history*. Exactly one step must be a ``TRIGGER``, every
+ transition must name steps in the same document, and no step may point
+ at itself.
+
+ Refused with 409 ``conflict`` while the workflow has running executions
+ -- those runs are standing on the steps being replaced. :meth:`pause`
+ first.
+ """
+ response: WorkflowGraphV1 = self._client.request(
+ method="PUT",
+ path=f"/api/v1/workflows/{encode_path_segment(id)}/graph",
+ body=body,
+ )
+ return response
+
+ def clone(self, id: str, body: Body) -> WorkflowRecord:
+ """Copy a workflow and its whole graph as a new workflow.
+
+ The copy is always created disabled, whatever the original was: a clone
+ exists to be reviewed, and one that started live would match the same
+ trigger events as its original from the moment it appeared. Pass
+ ``{"name": ...}`` to name it; it otherwise becomes ``Copy of ``.
+ """
+ response: WorkflowRecord = self._client.request(
+ method="POST",
+ path=f"/api/v1/workflows/{encode_path_segment(id)}/clone",
+ body=body,
+ )
+ return response
+
+ def pause(self, id: str) -> WorkflowStateChangeV1:
+ """Disable the workflow *and cancel every* ``RUNNING``/``WAITING``
+ execution in it, returning ``{workflow, cancelled_executions}``.
+
+ That is what separates this from ``update(id, {"enabled": False})``,
+ which only stops new runs starting and leaves every in-flight contact
+ walking the graph -- the next delay still expires, the next email still
+ sends.
+
+ The cancellation is terminal: :meth:`resume` re-opens the workflow to
+ new runs, it does not put the cancelled contacts back where they were.
+ """
+ response: WorkflowStateChangeV1 = self._client.request(
+ method="POST", path=f"/api/v1/workflows/{encode_path_segment(id)}/pause"
+ )
+ return response
+
+ def resume(self, id: str) -> WorkflowStateChangeV1:
+ """Re-enable the workflow so its trigger matches again.
+
+ ``cancelled_executions`` is always 0 here -- resuming starts nothing and
+ stops nothing. Refused with 422 ``validation_error`` while any step is
+ still unconfigured, the same rule ``update(id, {"enabled": True})``
+ enforces: an enabled workflow accepts contacts immediately and would
+ otherwise fail only once one reached the broken step.
+ """
+ response: WorkflowStateChangeV1 = self._client.request(
+ method="POST", path=f"/api/v1/workflows/{encode_path_segment(id)}/resume"
+ )
+ return response
diff --git a/src/sendly/types.py b/src/sendly/types.py
index 7b27712..b37f74a 100644
--- a/src/sendly/types.py
+++ b/src/sendly/types.py
@@ -40,7 +40,19 @@
BatchSendResponse = JSONDict
EmailRecord = JSONDict
EmailListResponse = JSONDict
-EmailGetResponse = JSONDict
+
+#: One transition in a message's delivery history -- the append-only record
+#: behind ``status``. ``status`` says where the message is now; these say how it
+#: got there.
+EmailEvent = JSONDict
+#: An email together with its delivery history, oldest first.
+EmailWithEvents = JSONDict
+#: A single email with no history -- what ``emails.cancel_schedule`` answers.
+#: Was ``EmailGetResponse``, which named the operation rather than the shape and
+#: was then reused by an operation that is not a GET.
+EmailResponse = JSONDict
+#: ``emails.get`` -- one email plus its delivery events.
+EmailDetailResponse = JSONDict
# The versioned send. Distinct from the legacy aliases above, which post to
# ``/api/emails`` and answer with row ids and no delivery status.
@@ -82,6 +94,14 @@
TemplateRecord = JSONDict
TemplateListResponse = JSONDict
+# ---------- Snippets ----------
+#
+# Reusable body fragments a template includes with ``{{> name}}``. Gated by the
+# same ``templates:*`` scopes as the templates that include them.
+
+SnippetRecord = JSONDict
+SnippetListResponse = JSONDict
+
# ---------- Webhooks ----------
WebhookRecord = JSONDict
@@ -147,3 +167,76 @@
TopCampaignList = JSONDict
UsageSummary = JSONDict
+
+# ---------- Contacts (v1) ----------
+
+ContactV1 = JSONDict
+ContactListV1 = CursorList
+ContactDeletedV1 = JSONDict
+#: Everything one contact has said they want, topic by topic.
+ContactTopicPreferencesV1 = JSONDict
+
+# ---------- Lists (v1) ----------
+
+ListV1 = JSONDict
+ListListV1 = CursorList
+ListDeletedV1 = JSONDict
+
+# ---------- Templates (v1) ----------
+
+TemplateV1 = JSONDict
+TemplateListV1 = CursorList
+TemplateDeletedV1 = JSONDict
+
+# ---------- Domains (v1) ----------
+
+DomainV1 = JSONDict
+DomainListV1 = CursorList
+DomainDeletedV1 = JSONDict
+
+# ---------- Webhooks (v1) ----------
+
+WebhookV1 = JSONDict
+WebhookListV1 = CursorList
+WebhookDeletedV1 = JSONDict
+#: The create response, and the only time the signing secret is readable.
+WebhookCreatedV1 = JSONDict
+#: Rotation answers the new secret once, for the same reason.
+WebhookSecretRotatedV1 = JSONDict
+
+# ---------- Suppressions (v1) ----------
+
+SuppressionV1 = JSONDict
+SuppressionListV1 = CursorList
+SuppressionDeletedV1 = JSONDict
+
+# ---------- Topics (v1) ----------
+
+TopicV1 = JSONDict
+TopicListV1 = CursorList
+TopicSubscriptionV1 = JSONDict
+
+# ---------- Email validation (v1) ----------
+
+EmailValidationBatchV1 = JSONDict
+EmailValidationRunV1 = JSONDict
+EmailValidationResultListV1 = CursorList
+
+# ---------- Deliverability (v1) ----------
+
+DeliverabilityDiagnosisV1 = JSONDict
+RecipientDomainStatsV1 = JSONDict
+RecipientDomainStatsListV1 = CursorList
+DmarcReportV1 = JSONDict
+DmarcReportListV1 = CursorList
+
+# ---------- Campaign failures (v1) ----------
+
+CampaignFailureV1 = JSONDict
+CampaignFailureListV1 = CursorList
+CampaignRetryFailedV1 = JSONDict
+
+# ---------- Workflow graph and lifecycle (v1) ----------
+
+WorkflowGraphV1 = JSONDict
+WorkflowStateChangeV1 = JSONDict
diff --git a/tests/fixtures/openapi.json b/tests/fixtures/openapi.json
index dff971c..2b76977 100644
--- a/tests/fixtures/openapi.json
+++ b/tests/fixtures/openapi.json
@@ -3,7 +3,7 @@
"parameters": {},
"schemas": {
"AddDomainBody": {
- "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": "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. `stream` assigns the identity to transactional or marketing traffic at creation; omit it to leave the identity serving both.",
"properties": {
"domain": {
"maxLength": 253,
@@ -22,6 +22,13 @@
"eu-west-1"
],
"type": "string"
+ },
+ "stream": {
+ "$ref": "#/components/schemas/SendingStream"
+ },
+ "streamDefault": {
+ "description": "Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it.",
+ "type": "boolean"
}
},
"required": [
@@ -235,16 +242,25 @@
"null"
]
},
- "name": {
- "type": "string"
- },
- "permission": {
+ "legacyGrantPreset": {
+ "description": "The coarse preset the key was minted under. It picks the token prefix (`sk_` vs `pk_`) and the rate-limit window, and it is the key's authority ONLY while `scopes` is empty — which is true just of keys minted before scopes existed. Read `scopes` to learn what a key can do.",
"enum": [
"FULL",
"SENDING_ONLY"
],
"type": "string"
},
+ "mode": {
+ "description": "Where mail sent with this key may go. `LIVE` sends from your verified domains. `TEST` can only send from this project's sandbox address, whose recipient list is your own verified account addresses, so a test key cannot reach a customer whatever it is asked to do. Fixed at creation.",
+ "enum": [
+ "LIVE",
+ "TEST"
+ ],
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
"projectId": {
"format": "uuid",
"type": "string"
@@ -258,7 +274,7 @@
]
},
"scopes": {
- "description": "The explicit scope grant this key carries. Empty on a key minted before this column existed — that row's grant is derived from `permission` instead at request time.",
+ "description": "The explicit scope grant this key carries. Empty on a key minted before this column existed — that row's grant is derived from `legacyGrantPreset` instead at request time.",
"items": {
"enum": [
"emails:send",
@@ -290,7 +306,15 @@
"campaigns:send",
"mailboxes:read",
"mailboxes:write",
- "emails:test"
+ "emails:test",
+ "deliverability:read",
+ "mailboxes:send",
+ "validation:read",
+ "validation:write",
+ "topics:read",
+ "topics:write",
+ "lists:read",
+ "lists:write"
],
"type": "string"
},
@@ -302,7 +326,8 @@
"projectId",
"name",
"lastFour",
- "permission",
+ "legacyGrantPreset",
+ "mode",
"scopes",
"domainId",
"lastUsedAt",
@@ -407,6 +432,36 @@
],
"type": "object"
},
+ "AssignDomainStream": {
+ "description": "Body for PATCH /api/domains/{id}.",
+ "properties": {
+ "defaultFromAddress": {
+ "description": "The address a send on this stream uses when it names none. Must be on this identity's own host — a default pointing elsewhere would go out unsigned by the name in the From header.",
+ "format": "email",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stream": {
+ "description": "Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address.",
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING",
+ null
+ ],
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "streamDefault": {
+ "description": "Make this the project's default identity for its stream, demoting whichever held it.",
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
"BatchEntryResult": {
"description": "Per-row result in a batch send response.",
"properties": {
@@ -488,7 +543,8 @@
"enum": [
"ALL",
"FILTERED",
- "SEGMENT"
+ "SEGMENT",
+ "LIST"
],
"type": "string"
},
@@ -500,6 +556,13 @@
"format": "uuid",
"type": "string"
},
+ "list_id": {
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"name": {
"type": "string"
},
@@ -561,6 +624,13 @@
},
"subject": {
"type": "string"
+ },
+ "topic_id": {
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
}
},
"required": [
@@ -569,6 +639,8 @@
"status",
"subject",
"audience_type",
+ "list_id",
+ "topic_id",
"scheduled_at",
"sent_at",
"created_at",
@@ -577,17 +649,18 @@
"type": "object"
},
"CampaignV1Create": {
- "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`.",
+ "description": "Body for POST /api/v1/campaigns. `segment_id` is required when `audience_type` is `SEGMENT`, `audience_condition` when it is `FILTERED`, and `list_id` when it is `LIST`.",
"properties": {
"audience_condition": {
"$ref": "#/components/schemas/FilterConditionV1"
},
"audience_type": {
- "description": "`ALL` — every subscribed contact. `FILTERED` — the contacts matching `audience_condition`. `SEGMENT` — the members of `segment_id`.",
+ "description": "`ALL` — every subscribed contact. `FILTERED` — the contacts matching `audience_condition`. `SEGMENT` — the members of `segment_id`. `LIST` — the CONFIRMED members of `list_id`, which is the only audience that honours double opt-in: a member who never confirmed, or who unsubscribed, is not mailed.",
"enum": [
"ALL",
"FILTERED",
- "SEGMENT"
+ "SEGMENT",
+ "LIST"
],
"type": "string"
},
@@ -599,6 +672,15 @@
"maxLength": 500,
"type": "string"
},
+ "email_category": {
+ "default": "MARKETING",
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING",
+ "SELF_MANAGED_UNSUBSCRIBE"
+ ],
+ "type": "string"
+ },
"from": {
"description": "Sender address. Its domain must be verified for this project.",
"format": "email",
@@ -611,6 +693,11 @@
"null"
]
},
+ "list_id": {
+ "description": "Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign.",
+ "format": "uuid",
+ "type": "string"
+ },
"name": {
"maxLength": 200,
"minLength": 1,
@@ -631,14 +718,13 @@
"minLength": 1,
"type": "string"
},
- "type": {
- "default": "MARKETING",
- "enum": [
- "TRANSACTIONAL",
- "MARKETING",
- "HEADLESS"
- ],
- "type": "string"
+ "topic_id": {
+ "description": "The subject this campaign is about. A contact who unsubscribed from the topic is excluded WHATEVER the audience type — a topic opt-out is a standing answer, not an audience filter, so it cannot be routed around by selecting a different audience. Ignored for `TRANSACTIONAL` campaigns, which a marketing preference does not cancel.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
}
},
"required": [
@@ -670,6 +756,74 @@
],
"type": "object"
},
+ "CampaignV1Failure": {
+ "description": "A campaign recipient whose send did not complete.",
+ "properties": {
+ "contact_id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "email": {
+ "description": "The recipient the send was for.",
+ "type": "string"
+ },
+ "failed_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "id": {
+ "description": "Ledger row id. Pass the last one as `after` to page.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "reason": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "contact_id",
+ "email",
+ "reason",
+ "failed_at"
+ ],
+ "type": "object"
+ },
+ "CampaignV1FailureList": {
+ "description": "Cursor-paginated list of a campaign's failed sends.",
+ "properties": {
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/CampaignV1Failure"
+ },
+ "type": "array"
+ },
+ "has_more": {
+ "type": "boolean"
+ },
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "total": {
+ "description": "Every FAILED row on this campaign, not just this page.",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "data",
+ "has_more",
+ "next_cursor",
+ "total"
+ ],
+ "type": "object"
+ },
"CampaignV1List": {
"description": "Cursor-paginated list of campaigns.",
"properties": {
@@ -697,6 +851,24 @@
],
"type": "object"
},
+ "CampaignV1RetryFailed": {
+ "description": "Acknowledgement that a retry of a campaign's failed sends began.",
+ "properties": {
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "queued": {
+ "description": "How many FAILED rows the retry walk was started for, counted when it was queued.",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "id",
+ "queued"
+ ],
+ "type": "object"
+ },
"CampaignV1Send": {
"description": "Body for POST /api/v1/campaigns/{id}/send.",
"properties": {
@@ -766,7 +938,8 @@
"enum": [
"ALL",
"FILTERED",
- "SEGMENT"
+ "SEGMENT",
+ "LIST"
],
"type": "string"
},
@@ -778,6 +951,14 @@
"maxLength": 500,
"type": "string"
},
+ "email_category": {
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING",
+ "SELF_MANAGED_UNSUBSCRIBE"
+ ],
+ "type": "string"
+ },
"from": {
"description": "Sender address. Its domain must be verified for this project.",
"format": "email",
@@ -790,6 +971,11 @@
"null"
]
},
+ "list_id": {
+ "description": "Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign.",
+ "format": "uuid",
+ "type": "string"
+ },
"name": {
"maxLength": 200,
"minLength": 1,
@@ -810,15 +996,61 @@
"minLength": 1,
"type": "string"
},
- "type": {
- "enum": [
- "TRANSACTIONAL",
- "MARKETING",
- "HEADLESS"
- ],
+ "topic_id": {
+ "description": "The subject this campaign is about. A contact who unsubscribed from the topic is excluded WHATEVER the audience type — a topic opt-out is a standing answer, not an audience filter, so it cannot be routed around by selecting a different audience. Ignored for `TRANSACTIONAL` campaigns, which a marketing preference does not cancel.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
+ },
+ "ComposeMailboxMessage": {
+ "description": "Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox.",
+ "properties": {
+ "bcc": {
+ "items": {
+ "format": "email",
+ "type": "string"
+ },
+ "maxItems": 20,
+ "type": "array"
+ },
+ "body": {
+ "maxLength": 50000,
+ "minLength": 1,
+ "type": "string"
+ },
+ "cc": {
+ "items": {
+ "format": "email",
+ "type": "string"
+ },
+ "maxItems": 20,
+ "type": "array"
+ },
+ "subject": {
+ "maxLength": 200,
+ "minLength": 1,
"type": "string"
+ },
+ "to": {
+ "items": {
+ "format": "email",
+ "type": "string"
+ },
+ "maxItems": 20,
+ "minItems": 1,
+ "type": "array"
}
},
+ "required": [
+ "to",
+ "subject",
+ "body"
+ ],
"type": "object"
},
"Contact": {
@@ -952,32 +1184,234 @@
],
"type": "object"
},
- "CreateApiKeyBody": {
+ "ContactTopicPreferencesV1": {
+ "description": "Everything this contact has said about what they want. `subscribed` on a topic already folds in `default_opt_in`, so a contact who has never answered still reads correctly.",
"properties": {
- "domainId": {
- "format": "uuid",
+ "contact_id": {
+ "type": "string"
+ },
+ "subscribed": {
+ "description": "The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say.",
+ "type": "boolean"
+ },
+ "topics": {
+ "items": {
+ "properties": {
+ "key": {
+ "description": "Stable, URL-safe, unique within the project. This is what a preference form and an API caller name the topic by, so it must survive a rename of `name` — which is the whole reason it exists beside one.",
+ "maxLength": 64,
+ "minLength": 1,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "pending": {
+ "type": "boolean"
+ },
+ "subscribed": {
+ "description": "The EFFECTIVE answer: what the send path concludes for this contact today.",
+ "type": "boolean"
+ },
+ "topic_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "topic_id",
+ "key",
+ "name",
+ "subscribed",
+ "pending"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "contact_id",
+ "subscribed",
+ "topics"
+ ],
+ "type": "object"
+ },
+ "ContactV1": {
+ "description": "A contact as exposed on the v1 API.",
+ "properties": {
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "custom_fields": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
"type": [
"string",
+ "number",
+ "boolean",
+ "object",
+ "array",
"null"
]
},
- "name": {
- "maxLength": 120,
- "minLength": 1,
+ "email": {
"type": "string"
},
- "permission": {
- "enum": [
- "FULL",
- "SENDING_ONLY"
- ],
+ "id": {
+ "format": "uuid",
"type": "string"
},
- "scopes": {
- "description": "The explicit grant the new key will carry. Omitted ⇒ materialised from `permission`. A `SENDING_ONLY` key may carry only `emails:send`.",
- "items": {
- "enum": [
- "emails:send",
+ "subscribed": {
+ "type": "boolean"
+ },
+ "updated_at": {
+ "format": "date-time",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "email",
+ "subscribed",
+ "custom_fields",
+ "created_at",
+ "updated_at"
+ ],
+ "type": "object"
+ },
+ "ContactV1Create": {
+ "description": "Body for POST /api/v1/contacts.",
+ "properties": {
+ "custom_fields": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`.",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "email": {
+ "format": "email",
+ "type": "string"
+ },
+ "subscribed": {
+ "default": true,
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "email"
+ ],
+ "type": "object"
+ },
+ "ContactV1Deleted": {
+ "description": "Acknowledgement that a contact was deleted.",
+ "properties": {
+ "deleted": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "deleted"
+ ],
+ "type": "object"
+ },
+ "ContactV1List": {
+ "description": "Cursor-paginated list of contacts.",
+ "properties": {
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/ContactV1"
+ },
+ "type": "array"
+ },
+ "has_more": {
+ "type": "boolean"
+ },
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "data",
+ "has_more",
+ "next_cursor"
+ ],
+ "type": "object"
+ },
+ "ContactV1Update": {
+ "description": "Body for PATCH /api/v1/contacts/{id}. `email` is deliberately absent: an address is the contact's identity on this API, and changing it in place would silently rewrite what every earlier send was addressed to. Create the new address instead.",
+ "properties": {
+ "custom_fields": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "subscribed": {
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
+ "CreateApiKeyBody": {
+ "properties": {
+ "domainId": {
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "legacyGrantPreset": {
+ "enum": [
+ "FULL",
+ "SENDING_ONLY"
+ ],
+ "type": "string"
+ },
+ "mode": {
+ "description": "`LIVE` (default) or `TEST`. A test key can only send from the project's sandbox address, which accepts only the project's own verified account addresses as recipients, so it cannot reach a customer. Its sends are real sends down the same pipeline and are marked `sentInTestMode`. Fixed at creation.",
+ "enum": [
+ "LIVE",
+ "TEST"
+ ],
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 120,
+ "minLength": 1,
+ "type": "string"
+ },
+ "scopes": {
+ "description": "The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`.",
+ "items": {
+ "enum": [
+ "emails:send",
"emails:read",
"contacts:read",
"contacts:write",
@@ -1006,7 +1440,15 @@
"campaigns:send",
"mailboxes:read",
"mailboxes:write",
- "emails:test"
+ "emails:test",
+ "deliverability:read",
+ "mailboxes:send",
+ "validation:read",
+ "validation:write",
+ "topics:read",
+ "topics:write",
+ "lists:read",
+ "lists:write"
],
"type": "string"
},
@@ -1103,6 +1545,32 @@
],
"type": "object"
},
+ "CreateSnippet": {
+ "description": "Body for POST /api/snippets.",
+ "properties": {
+ "body": {
+ "maxLength": 20000,
+ "minLength": 1,
+ "type": "string"
+ },
+ "description": {
+ "maxLength": 500,
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name": {
+ "pattern": "^[a-z][\\da-z_-]{0,63}$/i",
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "body"
+ ],
+ "type": "object"
+ },
"CreateTemplate": {
"description": "Body for POST /api/templates.",
"properties": {
@@ -1114,6 +1582,15 @@
"maxLength": 500,
"type": "string"
},
+ "emailCategory": {
+ "default": "MARKETING",
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING",
+ "SELF_MANAGED_UNSUBSCRIBE"
+ ],
+ "type": "string"
+ },
"from": {
"format": "email",
"type": "string"
@@ -1140,15 +1617,6 @@
"subject": {
"minLength": 1,
"type": "string"
- },
- "type": {
- "default": "MARKETING",
- "enum": [
- "TRANSACTIONAL",
- "MARKETING",
- "HEADLESS"
- ],
- "type": "string"
}
},
"required": [
@@ -1192,55 +1660,110 @@
],
"type": "object"
},
- "Domain": {
- "description": "A sending domain registered with SES.",
+ "DeliverabilityDiagnosisV1": {
+ "description": "A composed answer to why mail from one domain may not be arriving: its DNS identity, the project's recent delivery outcomes, one address's suppression state, and the findings drawn from them.",
"properties": {
- "createdAt": {
- "description": "ISO 8601 datetime string",
+ "address": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "checked_at": {
"format": "date-time",
"type": "string"
},
- "dkim": {
+ "domain": {
+ "type": "string"
+ },
+ "findings": {
+ "description": "What is wrong, worst first. An empty array means nothing here explains a delivery problem.",
"items": {
- "properties": {
- "name": {
- "type": "string"
- },
- "type": {
- "type": "string"
- },
- "value": {
- "type": "string"
- }
- },
- "required": [
- "type",
- "name",
- "value"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/DeliverabilityFindingV1"
},
"type": "array"
},
- "id": {
- "format": "uuid",
+ "identity": {
+ "$ref": "#/components/schemas/DeliverabilityIdentityV1"
+ },
+ "recent_delivery": {
+ "$ref": "#/components/schemas/DeliverabilityRecentDeliveryV1"
+ },
+ "suppression": {
+ "$ref": "#/components/schemas/DeliverabilitySuppressionV1"
+ }
+ },
+ "required": [
+ "domain",
+ "address",
+ "checked_at",
+ "identity",
+ "suppression",
+ "recent_delivery",
+ "findings"
+ ],
+ "type": "object"
+ },
+ "DeliverabilityFindingSeverityV1": {
+ "description": "`blocking`: mail from this domain cannot be delivered as configured. `degraded`: it delivers, but inbox placement or sender reputation is at risk. `info`: worth knowing, nothing to fix.",
+ "enum": [
+ "blocking",
+ "degraded",
+ "info"
+ ],
+ "type": "string"
+ },
+ "DeliverabilityFindingV1": {
+ "description": "One diagnosed problem, with its fix.",
+ "properties": {
+ "code": {
+ "description": "Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`.",
"type": "string"
},
- "mailFromDomain": {
- "description": "Custom MAIL FROM subdomain SES has on record (normally `sendly.`).",
+ "remedy": {
+ "description": "What to do about it.",
+ "type": "string"
+ },
+ "severity": {
+ "$ref": "#/components/schemas/DeliverabilityFindingSeverityV1"
+ },
+ "summary": {
+ "description": "What is wrong, in one sentence.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "code",
+ "severity",
+ "summary",
+ "remedy"
+ ],
+ "type": "object"
+ },
+ "DeliverabilityIdentityV1": {
+ "description": "The sending identity's DNS health, as last refreshed.",
+ "properties": {
+ "dkim_status": {
+ "description": "DKIM signing. This is the one that decides whether Sendly will send from the domain at all.",
+ "enum": [
+ "NOT_CHECKED",
+ "PENDING",
+ "VERIFIED",
+ "FAILED",
+ null
+ ],
"type": [
"string",
"null"
]
},
- "mailFromStatus": {
- "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.",
+ "dmarc_status": {
+ "description": "The DMARC policy published at `_dmarc.`.",
"enum": [
- "Pending",
- "Success",
- "Failed",
- "TemporaryFailure",
- "NotConfigured",
+ "NOT_CHECKED",
+ "PENDING",
+ "VERIFIED",
+ "FAILED",
null
],
"type": [
@@ -1248,99 +1771,152 @@
"null"
]
},
- "name": {
- "type": "string"
- },
- "projectId": {
- "format": "uuid",
- "type": "string"
+ "last_checked_at": {
+ "description": "When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
},
- "region": {
+ "mail_from_domain": {
"type": [
"string",
"null"
]
},
- "updatedAt": {
- "description": "ISO 8601 datetime string",
- "format": "date-time",
- "type": "string"
+ "mail_from_domain_status": {
+ "description": "Raw SES `CustomMailFromStatus` (`Pending`/`Success`/`Failed`/`TemporaryFailure`), or `NotConfigured`. `Failed` means SES silently fell back to `amazonses.com`, which is why the value is reported rather than folded into a boolean.",
+ "type": [
+ "string",
+ "null"
+ ]
},
- "verified": {
- "type": "boolean"
- }
+ "mx_status": {
+ "description": "Inbound receiving only. Null unless the domain has receiving enabled.",
+ "enum": [
+ "NOT_CHECKED",
+ "PENDING",
+ "VERIFIED",
+ "FAILED",
+ null
+ ],
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "registered": {
+ "description": "Whether this project has a domain record at all. False makes every other field null.",
+ "type": "boolean"
+ },
+ "spf_status": {
+ "description": "SPF alignment for the sending identity.",
+ "enum": [
+ "NOT_CHECKED",
+ "PENDING",
+ "VERIFIED",
+ "FAILED",
+ null
+ ],
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "verified": {
+ "type": "boolean"
+ }
},
"required": [
- "id",
- "projectId",
- "name",
+ "registered",
"verified",
- "createdAt",
- "updatedAt"
+ "dkim_status",
+ "spf_status",
+ "dmarc_status",
+ "mx_status",
+ "mail_from_domain",
+ "mail_from_domain_status",
+ "last_checked_at"
],
"type": "object"
},
- "DomainListResponse": {
- "description": "List of all domains for the auth'd project.",
+ "DeliverabilityRecentDeliveryV1": {
+ "description": "Delivery outcomes over the requested window.",
"properties": {
- "data": {
- "items": {
- "$ref": "#/components/schemas/Domain"
- },
- "type": "array"
+ "bounce_rate": {
+ "description": "Bounced ÷ sent (0–1), or null when nothing was sent in the window.",
+ "type": [
+ "number",
+ "null"
+ ]
},
- "success": {
+ "bounced": {
+ "type": "integer"
+ },
+ "complained": {
+ "type": "integer"
+ },
+ "complaint_rate": {
+ "type": [
+ "number",
+ "null"
+ ]
+ },
+ "delivered": {
+ "type": "integer"
+ },
+ "failed": {
+ "type": "integer"
+ },
+ "scope": {
+ "description": "PROJECT-WIDE, not per-domain, and said so rather than implied. `Email` has no sending-domain column, so narrowing these counters to one domain would mean a scan over every row the project has ever sent. A project that sends from one domain — most of them — can read these as that domain's.",
"enum": [
- true
+ "project"
],
- "type": "boolean"
+ "type": "string"
+ },
+ "sent": {
+ "type": "integer"
+ },
+ "window_days": {
+ "type": "integer"
}
},
"required": [
- "success",
- "data"
+ "window_days",
+ "scope",
+ "sent",
+ "delivered",
+ "bounced",
+ "complained",
+ "failed",
+ "bounce_rate",
+ "complaint_rate"
],
"type": "object"
},
- "DomainVerificationStatus": {
- "description": "Outcome of a verification check against SES.",
+ "DeliverabilitySuppressionV1": {
+ "description": "Null unless the request named an `address`.",
"properties": {
- "dkim": {
- "items": {
- "properties": {
- "name": {
- "type": "string"
- },
- "type": {
- "type": "string"
- },
- "value": {
- "type": "string"
- }
- },
- "required": [
- "type",
- "name",
- "value"
- ],
- "type": "object"
- },
- "type": "array"
- },
- "mailFromDomain": {
+ "reason": {
+ "enum": [
+ "HARD_BOUNCE",
+ "COMPLAINT",
+ "MANUAL",
+ "UNSUBSCRIBE",
+ null
+ ],
"type": [
"string",
"null"
]
},
- "mailFromStatus": {
- "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.",
+ "source": {
"enum": [
- "Pending",
- "Success",
- "Failed",
- "TemporaryFailure",
- "NotConfigured",
+ "SES_WEBHOOK",
+ "API",
+ "DASHBOARD",
null
],
"type": [
@@ -1348,416 +1924,474 @@
"null"
]
},
- "mxRecords": {
- "items": {
- "type": "string"
- },
- "type": "array"
- },
- "verified": {
+ "suppressed": {
"type": "boolean"
+ },
+ "suppressed_at": {
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
}
},
"required": [
- "verified"
+ "suppressed",
+ "reason",
+ "source",
+ "suppressed_at"
],
- "type": "object"
+ "type": [
+ "object",
+ "null"
+ ]
},
- "Email": {
- "description": "A sent (or queued) transactional email.",
+ "DmarcReportV1": {
+ "description": "One DMARC aggregate (RUA) report.",
"properties": {
- "createdAt": {
- "description": "ISO 8601 datetime string",
- "format": "date-time",
+ "fail_count": {
+ "type": "integer"
+ },
+ "id": {
"type": "string"
},
- "error": {
- "type": [
- "string",
- "null"
- ]
+ "org_name": {
+ "description": "The reporting receiver, e.g. `google.com`.",
+ "type": "string"
},
- "from": {
+ "pass_count": {
+ "description": "Messages DMARC-ALIGNED (SPF or DKIM aligned and passing), from `policy_evaluated`. Not the raw auth results: a message can pass SPF for a domain that is not the one in its From header, which is the case DMARC exists to catch.",
+ "type": "integer"
+ },
+ "policy_domain": {
+ "description": "The domain of yours the report is about.",
"type": "string"
},
- "id": {
- "format": "uuid",
+ "range_begin": {
+ "format": "date-time",
"type": "string"
},
- "projectId": {
- "format": "uuid",
+ "range_end": {
+ "format": "date-time",
"type": "string"
},
- "status": {
- "enum": [
- "PENDING",
- "SENT",
- "DELIVERED",
- "OPENED",
- "CLICKED",
- "BOUNCED",
- "COMPLAINED",
- "FAILED"
- ],
+ "received_at": {
+ "format": "date-time",
"type": "string"
},
- "subject": {
+ "report_id": {
+ "description": "The receiver's own id for this report.",
"type": "string"
},
- "tags": {
+ "sources": {
+ "description": "Per-sending-source rows, as the receiver reported them.",
"items": {
- "type": "string"
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "disposition": {
+ "type": "string"
+ },
+ "dkim": {
+ "type": "string"
+ },
+ "header_from": {
+ "type": "string"
+ },
+ "source_ip": {
+ "type": "string"
+ },
+ "spf": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "source_ip",
+ "count",
+ "disposition",
+ "dkim",
+ "spf",
+ "header_from"
+ ],
+ "type": "object"
},
"type": "array"
},
- "to": {
- "type": "string"
- },
- "updatedAt": {
- "description": "ISO 8601 datetime string",
- "format": "date-time",
- "type": "string"
+ "total_count": {
+ "type": "integer"
}
},
"required": [
"id",
- "projectId",
- "from",
- "to",
- "subject",
- "status",
- "tags",
- "createdAt",
- "updatedAt"
- ],
- "type": "object"
- },
- "EmailGetResponse": {
- "description": "Single email with its events.",
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Email"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
+ "report_id",
+ "org_name",
+ "policy_domain",
+ "range_begin",
+ "range_end",
+ "total_count",
+ "pass_count",
+ "fail_count",
+ "sources",
+ "received_at"
],
"type": "object"
},
- "EmailListResponse": {
- "description": "Cursor-paginated list of emails.",
+ "DmarcReportV1List": {
+ "description": "Cursor-paginated DMARC aggregate reports, newest window first.",
"properties": {
"data": {
"items": {
- "$ref": "#/components/schemas/Email"
+ "$ref": "#/components/schemas/DmarcReportV1"
},
"type": "array"
},
- "nextCursor": {
+ "has_more": {
+ "type": "boolean"
+ },
+ "intake_configured": {
+ "description": "Whether this deployment has a DMARC report intake mailbox configured. When `false` no report can ever arrive, so an empty `data` means the feature is off rather than that your domains are clean — the two are otherwise indistinguishable.",
+ "type": "boolean"
+ },
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
"type": [
"string",
"null"
]
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
}
},
"required": [
- "success",
- "data"
+ "data",
+ "has_more",
+ "next_cursor",
+ "intake_configured"
],
"type": "object"
},
- "EmailTestV1": {
- "description": "Receipt for a sandbox test send.",
+ "Domain": {
+ "description": "A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation.",
"properties": {
- "from": {
- "description": "This project's sandbox sender — resolved server-side, never from the body.",
- "format": "email",
+ "createdAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
"type": "string"
},
- "id": {
- "description": "The Email row this send created.",
- "format": "uuid",
- "type": "string"
+ "defaultFromAddress": {
+ "description": "The address a send on this stream uses when it names none. Always on this identity's own host.",
+ "type": [
+ "string",
+ "null"
+ ]
},
- "sandbox": {
- "description": "Always true. It is here so a model relaying this result cannot describe a test send as a real one: the message came from the shared sandbox domain and could only reach the project owner's own inbox.",
+ "dkimStatus": {
+ "description": "Result of the last DNS check for this record type.",
"enum": [
- true
+ "NOT_CHECKED",
+ "PENDING",
+ "VERIFIED",
+ "FAILED",
+ null
],
- "type": "boolean"
+ "type": [
+ "string",
+ "null"
+ ]
},
- "status": {
- "description": "Delivery status at the moment of the response — `PENDING` for a send still queued.",
+ "dkimTokens": {
+ "description": "SES DKIM tokens to publish as CNAME records before the domain can verify.",
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "dmarcStatus": {
+ "description": "Result of the last DNS check for this record type.",
"enum": [
+ "NOT_CHECKED",
"PENDING",
- "SENDING",
- "SENT",
- "DELIVERED",
- "RECEIVED",
- "OPENED",
- "CLICKED",
- "BOUNCED",
- "COMPLAINED",
+ "VERIFIED",
"FAILED",
- "REJECTED",
- "RENDERING_FAILURE",
- "DELIVERY_DELAY",
- "CANCELLED"
+ null
],
- "type": "string"
+ "type": [
+ "string",
+ "null"
+ ]
},
- "to": {
- "description": "The recipient the message was queued for.",
- "format": "email",
- "type": "string"
- }
- },
- "required": [
- "id",
- "status",
- "to",
- "from",
- "sandbox"
- ],
- "type": "object"
- },
- "EmailV1": {
- "description": "Receipt for a single transactional send.",
- "properties": {
- "from": {
- "description": "The sender actually used. Worth reading rather than assuming: it is resolved server-side and may come from the template when the request named none.",
- "format": "email",
+ "domain": {
+ "description": "The bare domain, e.g. `mail.acme.com`.",
"type": "string"
},
"id": {
- "description": "The Email row this send created. Quote it in support requests.",
"format": "uuid",
"type": "string"
},
- "status": {
- "description": "Delivery status at the moment of the response — `PENDING` for a send the worker has not picked up yet, which is the usual answer. Later states arrive via webhooks, not here.",
+ "lastHealthCheckAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "mailFromDomain": {
+ "description": "Custom MAIL FROM subdomain SES has on record (normally `sendly.`).",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "mailFromDomainStatus": {
+ "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.",
+ "enum": [
+ "Pending",
+ "Success",
+ "Failed",
+ "TemporaryFailure",
+ "NotConfigured",
+ null
+ ],
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "projectId": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "receivingEnabled": {
+ "description": "Whether inbound mail for this domain is routed to Sendly mailboxes.",
+ "type": "boolean"
+ },
+ "region": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "spfStatus": {
+ "description": "Result of the last DNS check for this record type.",
"enum": [
+ "NOT_CHECKED",
"PENDING",
- "SENDING",
- "SENT",
- "DELIVERED",
- "RECEIVED",
- "OPENED",
- "CLICKED",
- "BOUNCED",
- "COMPLAINED",
+ "VERIFIED",
"FAILED",
- "REJECTED",
- "RENDERING_FAILURE",
- "DELIVERY_DELAY",
- "CANCELLED"
+ null
],
- "type": "string"
+ "type": [
+ "string",
+ "null"
+ ]
},
- "to": {
- "description": "The recipient the message was queued for.",
- "format": "email",
+ "stream": {
+ "description": "Which traffic this identity carries. `null` means unassigned, and an unassigned identity carries every stream — the behaviour of every domain added before per-stream identities. A send whose stream does not match an ASSIGNED identity is refused.",
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING",
+ null
+ ],
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "streamDefault": {
+ "description": "Whether this is the project's default identity for its stream — the one a send picks when it names no from-address. At most one per (project, stream).",
+ "type": "boolean"
+ },
+ "updatedAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
"type": "string"
+ },
+ "verified": {
+ "type": "boolean"
}
},
"required": [
"id",
- "status",
- "to",
- "from"
+ "projectId",
+ "domain",
+ "verified",
+ "receivingEnabled",
+ "createdAt",
+ "updatedAt"
],
"type": "object"
},
- "Error": {
- "description": "Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`.",
+ "DomainListResponse": {
+ "description": "List of all domains for the auth'd project.",
"properties": {
- "error": {
- "properties": {
- "code": {
- "type": "string"
- },
- "details": {
- "properties": {
- "errors": {
- "items": {},
- "type": "array"
- }
- },
- "required": [
- "errors"
- ],
- "type": "object"
- },
- "message": {
- "type": "string"
- }
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/Domain"
},
- "required": [
- "message",
- "code"
- ],
- "type": "object"
+ "type": "array"
},
"success": {
"enum": [
- false
+ true
],
"type": "boolean"
}
},
"required": [
- "error"
+ "success",
+ "data"
],
"type": "object"
},
- "EventNamesV1": {
- "description": "Every distinct event name in the project, most frequent first.",
+ "DomainV1": {
+ "description": "A sending domain as exposed on the v1 API.",
"properties": {
- "data": {
- "items": {
- "type": "string"
- },
- "type": "array"
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "default_from_address": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "dkim_verified": {
+ "type": "boolean"
+ },
+ "domain": {
+ "type": "string"
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "mail_from_domain": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "mail_from_domain_status": {
+ "description": "SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "region": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stream": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/SendingStream"
+ },
+ {
+ "description": "Which traffic a sending identity carries. An identity with no stream serves both, which is how every domain added before per-stream identities behaves.",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ ]
+ },
+ "stream_default": {
+ "type": "boolean"
+ },
+ "updated_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "verified": {
+ "type": "boolean"
}
},
"required": [
- "data"
+ "id",
+ "domain",
+ "verified",
+ "region",
+ "stream",
+ "stream_default",
+ "default_from_address",
+ "mail_from_domain",
+ "mail_from_domain_status",
+ "dkim_verified",
+ "created_at",
+ "updated_at"
],
"type": "object"
},
- "EventStatsV1": {
- "description": "Per-name event counts over the applied window.",
+ "DomainV1Create": {
+ "description": "Body for POST /api/v1/domains.",
"properties": {
- "data": {
- "items": {
- "properties": {
- "count": {
- "type": "integer"
- },
- "name": {
- "type": "string"
- }
- },
- "required": [
- "name",
- "count"
- ],
- "type": "object"
- },
- "type": "array"
+ "domain": {
+ "maxLength": 253,
+ "minLength": 3,
+ "type": "string"
},
- "window": {
- "$ref": "#/components/schemas/AnalyticsWindowV1"
- }
- },
- "required": [
- "data",
- "window"
- ],
- "type": "object"
- },
- "EventTrackV1": {
- "description": "Body for POST /api/v1/events.",
- "properties": {
- "contact_id": {
- "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.",
- "format": "uuid",
+ "region": {
+ "description": "AWS SES region for the project. Once a domain is added the region is locked and cannot be changed.",
+ "enum": [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1"
+ ],
"type": "string"
},
- "data": {
- "additionalProperties": {
- "additionalProperties": {},
- "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
- "type": [
- "string",
- "number",
- "boolean",
- "object",
- "array",
- "null"
- ]
- },
- "description": "Arbitrary event payload.",
- "type": "object"
+ "stream": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/SendingStream"
+ },
+ {
+ "description": "Which traffic a sending identity carries. An identity with no stream serves both, which is how every domain added before per-stream identities behaves."
+ }
+ ]
},
- "name": {
- "description": "Event name, e.g. `user.signup`.",
- "maxLength": 200,
- "minLength": 1,
- "type": "string"
+ "stream_default": {
+ "description": "Make this the project's default identity for `stream`. Requires `stream`.",
+ "type": "boolean"
}
},
"required": [
- "name"
+ "domain"
],
"type": "object"
},
- "EventV1": {
- "description": "A recorded custom event.",
+ "DomainV1Deleted": {
+ "description": "Acknowledgement that a sending domain was removed.",
"properties": {
- "contact_id": {
- "format": "uuid",
- "type": [
- "string",
- "null"
- ]
- },
- "created_at": {
- "format": "date-time",
- "type": "string"
- },
- "data": {
- "additionalProperties": {},
- "description": "The payload recorded with the event, or null.",
- "type": [
- "object",
- "null"
- ]
- },
- "email_id": {
- "format": "uuid",
- "type": [
- "string",
- "null"
- ]
+ "deleted": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
},
"id": {
"format": "uuid",
"type": "string"
- },
- "name": {
- "type": "string"
}
},
"required": [
"id",
- "name",
- "contact_id",
- "email_id",
- "data",
- "created_at"
+ "deleted"
],
"type": "object"
},
- "EventV1List": {
- "description": "Cursor-paginated list of events, newest first.",
+ "DomainV1List": {
+ "description": "Cursor-paginated list of sending domains.",
"properties": {
"data": {
"items": {
- "$ref": "#/components/schemas/EventV1"
+ "$ref": "#/components/schemas/DomainV1"
},
"type": "array"
},
@@ -1779,145 +2413,275 @@
],
"type": "object"
},
- "FilterConditionV1": {
- "description": "A filter condition: one or more groups combined with `logic`.",
+ "DomainVerificationStatus": {
+ "description": "Outcome of a verification check against SES.",
"properties": {
- "groups": {
- "items": {
- "$ref": "#/components/schemas/FilterGroupV1"
- },
- "minItems": 1,
- "type": "array"
+ "dkimStatus": {
+ "enum": [
+ "VERIFIED",
+ "PENDING",
+ "FAILED"
+ ],
+ "type": "string"
},
- "logic": {
+ "dmarcStatus": {
"enum": [
- "AND",
- "OR"
+ "VERIFIED",
+ "FAILED",
+ "NOT_CHECKED"
],
"type": "string"
- }
- },
- "required": [
- "logic",
- "groups"
- ],
- "type": "object"
- },
- "FilterGroupV1": {
- "description": "A group of filters. The filters inside one group ALWAYS combine with AND; `conditions` nests a further condition under this group, which is how OR-of-ANDs (and deeper) is expressed.",
- "properties": {
- "conditions": {
- "$ref": "#/components/schemas/FilterConditionV1"
},
- "filters": {
+ "domain": {
+ "type": "string"
+ },
+ "mailFromDomain": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "mailFromDomainStatus": {
+ "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.",
+ "enum": [
+ "Pending",
+ "Success",
+ "Failed",
+ "TemporaryFailure",
+ "NotConfigured",
+ null
+ ],
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "spfStatus": {
+ "enum": [
+ "VERIFIED",
+ "FAILED",
+ "NOT_CHECKED"
+ ],
+ "type": "string"
+ },
+ "status": {
+ "description": "Raw SES DKIM verification status, e.g. `Success` or `Pending`.",
+ "type": "string"
+ },
+ "tokens": {
+ "description": "DKIM tokens SES still has to report. Absent once verification has resolved.",
"items": {
- "$ref": "#/components/schemas/SegmentFilterV1"
+ "type": "string"
},
"type": "array"
+ },
+ "verified": {
+ "type": "boolean"
}
},
"required": [
- "filters"
+ "domain",
+ "status",
+ "verified",
+ "dkimStatus",
+ "spfStatus",
+ "dmarcStatus",
+ "mailFromDomain"
],
"type": "object"
},
- "IdResponse": {
- "description": "Success envelope carrying the affected resource's id, e.g. after a delete.",
+ "DraftMailboxMessage": {
+ "description": "Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending.",
"properties": {
- "data": {
- "properties": {
- "id": {
- "format": "uuid",
- "type": "string"
- }
- },
- "required": [
- "id"
+ "brief": {
+ "maxLength": 4000,
+ "type": "string"
+ },
+ "draft": {
+ "maxLength": 20000,
+ "type": "string"
+ },
+ "instruction": {
+ "maxLength": 500,
+ "type": "string"
+ },
+ "mode": {
+ "enum": [
+ "draft",
+ "rewrite",
+ "subject"
],
- "type": "object"
+ "type": "string"
},
- "success": {
+ "recipientContext": {
+ "maxLength": 2000,
+ "type": "string"
+ },
+ "senderAddress": {
+ "maxLength": 320,
+ "type": "string"
+ },
+ "tone": {
"enum": [
- true
+ "friendly",
+ "neutral",
+ "formal",
+ "apologetic",
+ "direct"
],
- "type": "boolean"
+ "type": "string"
}
},
"required": [
- "success",
- "data"
+ "mode"
],
"type": "object"
},
- "ListSubscribe": {
- "description": "Body for POST /api/lists/{id}/subscribe.",
+ "Email": {
+ "description": "A sent (or queued) transactional email.",
"properties": {
- "allowResubscribe": {
- "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.",
- "type": "boolean"
+ "bouncedAt": {
+ "description": "Bounced, or null.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
},
- "data": {
- "additionalProperties": {},
- "description": "Custom fields to upsert onto the contact as part of subscribing.",
- "type": "object"
+ "clickedAt": {
+ "description": "First click, or null.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
},
- "email": {
- "format": "email",
+ "clicks": {
+ "description": "Total clicks recorded.",
+ "type": "integer"
+ },
+ "complainedAt": {
+ "description": "Spam complaint, or null.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "createdAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
"type": "string"
- }
- },
- "required": [
- "email"
- ],
- "type": "object"
- },
- "ListSubscribeResponse": {
- "description": "Result of a list-subscribe call.",
- "properties": {
- "data": {
- "properties": {
- "confirmToken": {
- "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.",
- "type": "string"
- },
- "created": {
- "description": "True when the membership row did not exist before this call.",
- "type": "boolean"
- },
- "membershipId": {
- "format": "uuid",
- "type": "string"
- },
- "previousStatus": {
- "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.",
- "enum": [
- "PENDING",
- "CONFIRMED",
- "UNSUBSCRIBED",
- null
- ],
- "type": [
- "string",
- "null"
- ]
- },
- "status": {
- "enum": [
- "PENDING",
- "CONFIRMED",
- "UNSUBSCRIBED"
- ],
- "type": "string"
- }
+ },
+ "deliveredAt": {
+ "description": "Accepted by the recipient's server, or null.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "from": {
+ "type": "string"
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "openedAt": {
+ "description": "First open, or null.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "opens": {
+ "description": "Total opens recorded.",
+ "type": "integer"
+ },
+ "projectId": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "sentAt": {
+ "description": "Handed to the provider, or null.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "status": {
+ "$ref": "#/components/schemas/EmailDeliveryStatus"
+ },
+ "subject": {
+ "type": "string"
+ },
+ "tags": {
+ "items": {
+ "type": "string"
},
- "required": [
- "membershipId",
- "status",
- "created",
- "previousStatus"
- ],
- "type": "object"
+ "type": "array"
+ },
+ "to": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "projectId",
+ "from",
+ "to",
+ "subject",
+ "status",
+ "sentAt",
+ "deliveredAt",
+ "bouncedAt",
+ "openedAt",
+ "clickedAt",
+ "complainedAt",
+ "opens",
+ "clicks",
+ "tags",
+ "createdAt",
+ "updatedAt"
+ ],
+ "type": "object"
+ },
+ "EmailDeliveryStatus": {
+ "description": "Delivery lifecycle of the message. Engagement is reported separately.",
+ "enum": [
+ "PENDING",
+ "SENDING",
+ "SENT",
+ "DELIVERED",
+ "RECEIVED",
+ "BOUNCED",
+ "FAILED",
+ "REJECTED",
+ "RENDERING_FAILURE",
+ "DELIVERY_DELAY",
+ "CANCELLED"
+ ],
+ "type": "string"
+ },
+ "EmailDetailResponse": {
+ "description": "One email and its delivery history.",
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/EmailWithEvents"
},
"success": {
"enum": [
@@ -1932,33 +2696,62 @@
],
"type": "object"
},
- "ListUnsubscribe": {
- "description": "Body for POST /api/lists/{id}/unsubscribe.",
+ "EmailEvent": {
+ "description": "One transition in a message's delivery history.",
"properties": {
- "email": {
- "format": "email",
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "status": {
+ "$ref": "#/components/schemas/EmailDeliveryStatus"
+ },
+ "timestamp": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
"type": "string"
}
},
"required": [
- "email"
+ "id",
+ "status",
+ "timestamp"
],
"type": "object"
},
- "ListUnsubscribeResponse": {
- "description": "Echoes the address that was unsubscribed.",
+ "EmailListResponse": {
+ "description": "Cursor-paginated list of emails.",
"properties": {
"data": {
- "properties": {
- "email": {
- "format": "email",
- "type": "string"
- }
+ "items": {
+ "$ref": "#/components/schemas/Email"
},
- "required": [
- "email"
+ "type": "array"
+ },
+ "nextCursor": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "success": {
+ "enum": [
+ true
],
- "type": "object"
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "EmailResponse": {
+ "description": "A single email.",
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Email"
},
"success": {
"enum": [
@@ -1973,415 +2766,510 @@
],
"type": "object"
},
- "Mailbox": {
- "description": "A receiving mailbox on one of the project's verified domains.",
+ "EmailTestV1": {
+ "description": "Receipt for a sandbox test send.",
"properties": {
- "address": {
- "description": "The full mailbox address, e.g. `support@superbooks.io`.",
+ "from": {
+ "description": "This project's sandbox sender — resolved server-side, never from the body.",
"format": "email",
"type": "string"
},
- "createdAt": {
- "format": "date-time",
- "type": "string"
- },
- "displayName": {
- "type": [
- "string",
- "null"
- ]
- },
- "domainId": {
- "description": "The verified domain this mailbox lives on.",
- "format": "uuid",
- "type": "string"
- },
"id": {
+ "description": "The Email row this send created.",
"format": "uuid",
"type": "string"
},
- "quotaBytes": {
- "description": "Always null. Mailbox quotas are not implemented — the value was never applied to the mail account — so this field reports the absence rather than a number nothing enforces.",
- "type": [
- "number",
- "null"
- ]
+ "sandbox": {
+ "description": "Always true. It is here so a model relaying this result cannot describe a test send as a real one: the message came from the shared sandbox domain and could only reach the project owner's own inbox.",
+ "enum": [
+ true
+ ],
+ "type": "boolean"
},
"status": {
- "description": "`PROVISIONING` while the mail account is being created, `ACTIVE` once it can receive, `SUSPENDED` when receiving is paused, `FAILED` when provisioning did not complete. A `FAILED` mailbox can be re-created with the same address — the retry reclaims the row.",
+ "description": "Delivery status at the moment of the response — `PENDING` for a send still queued.",
"enum": [
- "PROVISIONING",
- "ACTIVE",
- "SUSPENDED",
- "FAILED"
+ "PENDING",
+ "SENDING",
+ "SENT",
+ "DELIVERED",
+ "RECEIVED",
+ "BOUNCED",
+ "FAILED",
+ "REJECTED",
+ "RENDERING_FAILURE",
+ "DELIVERY_DELAY",
+ "CANCELLED"
],
"type": "string"
+ },
+ "to": {
+ "description": "The recipient the message was queued for.",
+ "format": "email",
+ "type": "string"
}
},
"required": [
"id",
- "address",
- "displayName",
"status",
- "quotaBytes",
- "domainId",
- "createdAt"
+ "to",
+ "from",
+ "sandbox"
],
"type": "object"
},
- "MailboxDetail": {
- "allOf": [
- {
- "$ref": "#/components/schemas/Mailbox"
+ "EmailV1": {
+ "description": "Receipt for a single transactional send.",
+ "properties": {
+ "from": {
+ "description": "The sender actually used. Worth reading rather than assuming: it is resolved server-side and may come from the template when the request named none.",
+ "format": "email",
+ "type": "string"
},
- {
- "properties": {
- "settings": {
- "description": "Host, port and username for connecting a mail client. The PASSWORD is not here and is never returned by this endpoint — create an app password for that.",
- "properties": {
- "imap": {
- "properties": {
- "host": {
- "type": "string"
- },
- "port": {
- "type": "integer"
- },
- "security": {
- "description": "Transport security, e.g. `SSL/TLS`.",
- "type": "string"
- },
- "username": {
- "description": "The mailbox address — it is also the login.",
- "type": "string"
- }
- },
- "required": [
- "host",
- "port",
- "security",
- "username"
- ],
- "type": "object"
- },
- "smtp": {
- "properties": {
- "host": {
- "type": "string"
- },
- "port": {
- "type": "integer"
- },
- "security": {
- "description": "Transport security, e.g. `SSL/TLS`.",
- "type": "string"
- },
- "username": {
- "description": "The mailbox address — it is also the login.",
- "type": "string"
- }
- },
- "required": [
- "host",
- "port",
- "security",
- "username"
- ],
- "type": "object"
- }
- },
- "required": [
- "imap",
- "smtp"
- ],
- "type": "object"
- }
- },
- "required": [
- "settings"
+ "id": {
+ "description": "The Email row this send created. Quote it in support requests.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "status": {
+ "description": "Delivery status at the moment of the response — `PENDING` for a send the worker has not picked up yet, which is the usual answer. Later states arrive via webhooks, not here.",
+ "enum": [
+ "PENDING",
+ "SENDING",
+ "SENT",
+ "DELIVERED",
+ "RECEIVED",
+ "BOUNCED",
+ "FAILED",
+ "REJECTED",
+ "RENDERING_FAILURE",
+ "DELIVERY_DELAY",
+ "CANCELLED"
],
- "type": "object"
+ "type": "string"
+ },
+ "to": {
+ "description": "The recipient the message was queued for.",
+ "format": "email",
+ "type": "string"
}
+ },
+ "required": [
+ "id",
+ "status",
+ "to",
+ "from"
],
- "description": "A mailbox plus its IMAP/SMTP connection settings."
+ "type": "object"
},
- "Problem": {
- "description": "RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface.",
+ "EmailValidationBatchRequestV1": {
"properties": {
- "code": {
- "description": "Machine-readable lowercase error code, e.g. `scope_missing`.",
- "type": "string"
- },
- "detail": {
- "description": "Explanation specific to this occurrence.",
- "type": "string"
- },
- "errors": {
- "description": "Field-level failures. Present on 422 `validation_error` responses.",
+ "emails": {
+ "description": "The addresses to check, at most 50. Every distinct DOMAIN costs a DNS round trip, so this endpoint is bounded by latency rather than payload size — validate a whole list with `POST /api/v1/lists/{id}/validation-runs`, which is a background job.",
"items": {
- "properties": {
- "code": {
- "type": "string"
- },
- "message": {
- "type": "string"
- },
- "pointer": {
- "description": "RFC 6901 JSON Pointer to the offending field.",
- "type": "string"
- }
- },
- "required": [
- "pointer",
- "code",
- "message"
- ],
- "type": "object"
+ "format": "email",
+ "type": "string"
},
+ "maxItems": 50,
+ "minItems": 1,
"type": "array"
- },
- "instance": {
- "description": "Request path the failure occurred on.",
- "type": "string"
- },
- "request_id": {
- "description": "Correlation id — quote it in support requests.",
- "type": "string"
- },
- "status": {
- "description": "HTTP status code, repeated in the body.",
- "type": "integer"
- },
- "title": {
- "description": "Short, stable summary — the same for every occurrence of a `type`.",
- "type": "string"
- },
- "type": {
- "description": "Dereferenceable URI identifying the error class, anchored on the docs errors page.",
- "format": "uri",
- "type": "string"
}
},
"required": [
- "type",
- "title",
- "status",
- "code"
+ "emails"
],
"type": "object"
},
- "ProjectRecord": {
+ "EmailValidationBatchV1": {
+ "description": "One verdict per address, in the order they were given.",
"properties": {
- "billingLimitCampaigns": {
- "type": [
- "integer",
- "null"
- ]
+ "results": {
+ "items": {
+ "$ref": "#/components/schemas/EmailValidationV1"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "results"
+ ],
+ "type": "object"
+ },
+ "EmailValidationResultListV1": {
+ "description": "One page of a run's results. No total — a run over a large list holds millions of rows, and the run's own counters are the numbers worth reading.",
+ "properties": {
+ "data": {
+ "items": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/EmailValidationV1"
+ },
+ {
+ "properties": {
+ "contact_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "contact_id"
+ ],
+ "type": "object"
+ }
+ ],
+ "description": "One address's verdict, with the evidence behind it."
+ },
+ "type": "array"
},
- "billingLimitInbound": {
- "type": [
- "integer",
- "null"
- ]
+ "has_more": {
+ "type": "boolean"
},
- "billingLimitTransactional": {
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
"type": [
- "integer",
+ "string",
"null"
]
- },
- "billingLimitWorkflows": {
+ }
+ },
+ "required": [
+ "data",
+ "has_more",
+ "next_cursor"
+ ],
+ "type": "object"
+ },
+ "EmailValidationRunV1": {
+ "description": "One bulk validation run over a list.",
+ "properties": {
+ "completed_at": {
+ "format": "date-time",
"type": [
- "integer",
+ "string",
"null"
]
},
- "createdAt": {
- "description": "ISO 8601 datetime string",
+ "created_at": {
"format": "date-time",
"type": "string"
},
- "disabled": {
- "type": "boolean"
+ "deliverable_count": {
+ "type": "integer"
},
- "disabledReason": {
+ "failure_reason": {
+ "description": "Set only on `failed`. Prose for an operator; never parse it.",
"type": [
"string",
"null"
]
},
"id": {
- "format": "uuid",
"type": "string"
},
- "language": {
- "description": "ISO 639-1 code for customer-facing content.",
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "organizationId": {
+ "list_id": {
"type": [
"string",
"null"
]
},
- "sandboxHandle": {
- "description": "Local-part of the sandbox quick-start sender; null until first derived.",
- "type": [
- "string",
- "null"
- ]
- },
- "sesRegion": {
- "type": [
- "string",
- "null"
- ]
+ "processed_count": {
+ "description": "Addresses checked so far. There is deliberately no total: a list changes size while a run walks it, so a denominator captured up front would be wrong by the time you read it.",
+ "type": "integer"
},
- "stripeCustomerId": {
- "type": [
- "string",
- "null"
- ]
+ "risky_count": {
+ "type": "integer"
},
- "stripeSubscriptionId": {
+ "started_at": {
+ "format": "date-time",
"type": [
"string",
"null"
]
},
- "tracking": {
+ "status": {
"enum": [
- "ENABLED",
- "DISABLED",
- "MARKETING_ONLY"
+ "pending",
+ "running",
+ "completed",
+ "failed"
],
"type": "string"
},
- "updatedAt": {
- "description": "ISO 8601 datetime string",
- "format": "date-time",
- "type": "string"
+ "undeliverable_count": {
+ "type": "integer"
}
},
"required": [
"id",
- "name",
- "disabled",
- "disabledReason",
- "sandboxHandle",
- "stripeCustomerId",
- "stripeSubscriptionId",
- "billingLimitWorkflows",
- "billingLimitCampaigns",
- "billingLimitTransactional",
- "billingLimitInbound",
- "tracking",
- "sesRegion",
- "language",
- "organizationId",
- "createdAt",
- "updatedAt"
+ "list_id",
+ "status",
+ "processed_count",
+ "deliverable_count",
+ "undeliverable_count",
+ "risky_count",
+ "started_at",
+ "completed_at",
+ "failure_reason",
+ "created_at"
],
"type": "object"
},
- "ProjectV1": {
- "description": "The project the presented credential is scoped to.",
+ "EmailValidationV1": {
+ "description": "One address's verdict, with the evidence behind it.",
"properties": {
- "created_at": {
- "format": "date-time",
+ "email": {
"type": "string"
},
- "disabled": {
- "description": "A disabled project sends nothing; every send is refused.",
+ "has_mx_records": {
+ "description": "The domain publishes MX records.",
"type": "boolean"
},
- "id": {
- "format": "uuid",
- "type": "string"
+ "is_disposable": {
+ "description": "A throwaway-inbox provider. The ONLY flag here that lowers the verdict.",
+ "type": "boolean"
},
- "language": {
- "description": "ISO 639-1 code for customer-facing content.",
- "type": "string"
+ "is_personal": {
+ "description": "A free/consumer provider (Gmail, Outlook). List-quality information, not a problem.",
+ "type": "boolean"
},
- "name": {
- "type": "string"
+ "is_role_address": {
+ "description": "The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them.",
+ "type": "boolean"
},
- "sandbox_address": {
- "description": "This project's quick-start sender, usable with no domain setup — but only to the project owner's own verified address, and under a daily cap. Null when none can be derived.",
- "type": [
- "string",
- "null"
- ]
+ "reasons": {
+ "description": "Human-readable findings. Prose for a person to read — branch on `verdict`, never on these.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
},
- "ses_region": {
- "description": "Locked once the first domain is added.",
- "type": [
- "string",
- "null"
- ]
+ "verdict": {
+ "$ref": "#/components/schemas/EmailValidationVerdictV1"
+ }
+ },
+ "required": [
+ "email",
+ "verdict",
+ "is_disposable",
+ "is_role_address",
+ "is_personal",
+ "has_mx_records",
+ "reasons"
+ ],
+ "type": "object"
+ },
+ "EmailValidationVerdictV1": {
+ "description": "`deliverable`: the domain resolves and accepts mail, with no badness signal. `undeliverable`: the domain does not exist or publishes no MX records. `risky`: deliverable, but a throwaway-inbox provider — mailing it costs sender reputation. `unknown`: DNS did not answer in time, so this address was NOT checked. Ask again; never delete a contact on `unknown`.",
+ "enum": [
+ "deliverable",
+ "undeliverable",
+ "risky",
+ "unknown"
+ ],
+ "type": "string"
+ },
+ "EmailWithEvents": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Email"
},
- "tracking": {
+ {
+ "properties": {
+ "events": {
+ "description": "Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource.",
+ "items": {
+ "$ref": "#/components/schemas/EmailEvent"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "events"
+ ],
+ "type": "object"
+ }
+ ],
+ "description": "A transactional email together with its delivery history."
+ },
+ "Error": {
+ "description": "Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`.",
+ "properties": {
+ "error": {
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "details": {
+ "properties": {
+ "errors": {
+ "items": {},
+ "type": "array"
+ }
+ },
+ "required": [
+ "errors"
+ ],
+ "type": "object"
+ },
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "message",
+ "code"
+ ],
+ "type": "object"
+ },
+ "success": {
"enum": [
- "ENABLED",
- "DISABLED",
- "MARKETING_ONLY"
+ false
],
- "type": "string"
+ "type": "boolean"
}
},
"required": [
- "id",
- "name",
- "disabled",
- "sandbox_address",
- "ses_region",
- "tracking",
- "language",
- "created_at"
+ "error"
],
"type": "object"
},
- "SegmentContactV1": {
- "description": "A contact belonging to a segment.",
+ "EventNamesV1": {
+ "description": "Every distinct event name in the project, most frequent first.",
"properties": {
- "created_at": {
- "format": "date-time",
+ "data": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "data"
+ ],
+ "type": "object"
+ },
+ "EventStatsV1": {
+ "description": "Per-name event counts over the applied window.",
+ "properties": {
+ "data": {
+ "items": {
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "count"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "window": {
+ "$ref": "#/components/schemas/AnalyticsWindowV1"
+ }
+ },
+ "required": [
+ "data",
+ "window"
+ ],
+ "type": "object"
+ },
+ "EventTrackV1": {
+ "description": "Body for POST /api/v1/events.",
+ "properties": {
+ "contact_id": {
+ "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.",
+ "format": "uuid",
"type": "string"
},
- "custom_fields": {
- "additionalProperties": {},
- "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "name": {
+ "description": "Event name, e.g. `user.signup`.",
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "payload": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "description": "Arbitrary event payload.",
"type": "object"
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "type": "object"
+ },
+ "EventV1": {
+ "description": "A recorded custom event.",
+ "properties": {
+ "contact_id": {
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
},
- "email": {
+ "created_at": {
+ "format": "date-time",
"type": "string"
},
+ "email_id": {
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"id": {
"format": "uuid",
"type": "string"
},
- "subscribed": {
- "type": "boolean"
+ "name": {
+ "type": "string"
+ },
+ "payload": {
+ "additionalProperties": {},
+ "description": "The payload recorded with the event, or null.",
+ "type": [
+ "object",
+ "null"
+ ]
}
},
"required": [
"id",
- "email",
- "subscribed",
- "custom_fields",
+ "name",
+ "contact_id",
+ "email_id",
+ "payload",
"created_at"
],
"type": "object"
},
- "SegmentContactV1List": {
- "description": "Cursor-paginated list of the contacts belonging to a segment.",
+ "EventV1List": {
+ "description": "Cursor-paginated list of events, newest first.",
"properties": {
"data": {
"items": {
- "$ref": "#/components/schemas/SegmentContactV1"
+ "$ref": "#/components/schemas/EventV1"
},
"type": "array"
},
@@ -2403,63 +3291,208 @@
],
"type": "object"
},
- "SegmentFilterV1": {
- "description": "One comparison. `field` addresses a contact column (`email`, `createdAt`, …) or a `customFields.` path. `value` is whatever the operator compares against — its JSON type follows the field, and it is omitted entirely for the presence operators (`exists`, `notExists`). `unit` applies only to the relative-time operators (`within`, `olderThan`, and the `triggered*` family).",
+ "FilterConditionV1": {
+ "description": "A filter condition: one or more groups combined with `logic`.",
"properties": {
- "field": {
- "minLength": 1,
- "type": "string"
- },
- "operator": {
- "enum": [
- "equals",
- "notEquals",
- "contains",
- "notContains",
- "greaterThan",
- "lessThan",
- "greaterThanOrEqual",
- "lessThanOrEqual",
- "exists",
- "notExists",
- "within",
- "olderThan",
- "triggered",
- "triggeredWithin",
- "triggeredOlderThan",
- "notTriggered",
- "notTriggeredWithin",
- "isMemberOf"
+ "groups": {
+ "items": {
+ "$ref": "#/components/schemas/FilterGroupV1"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "logic": {
+ "enum": [
+ "AND",
+ "OR"
],
"type": "string"
+ }
+ },
+ "required": [
+ "logic",
+ "groups"
+ ],
+ "type": "object"
+ },
+ "FilterGroupV1": {
+ "description": "A group of filters. The filters inside one group ALWAYS combine with AND; `conditions` nests a further condition under this group, which is how OR-of-ANDs (and deeper) is expressed.",
+ "properties": {
+ "conditions": {
+ "$ref": "#/components/schemas/FilterConditionV1"
},
- "unit": {
+ "filters": {
+ "items": {
+ "$ref": "#/components/schemas/SegmentFilterV1"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "filters"
+ ],
+ "type": "object"
+ },
+ "IdResponse": {
+ "description": "Success envelope carrying the affected resource's id, e.g. after a delete.",
+ "properties": {
+ "data": {
+ "properties": {
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "type": "object"
+ },
+ "success": {
"enum": [
- "days",
- "hours",
- "minutes"
+ true
],
- "type": "string"
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "ListSubscribe": {
+ "description": "Body for POST /api/lists/{id}/subscribe.",
+ "properties": {
+ "allowResubscribe": {
+ "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.",
+ "type": "boolean"
},
- "value": {}
+ "data": {
+ "additionalProperties": {},
+ "description": "Custom fields to upsert onto the contact as part of subscribing.",
+ "type": "object"
+ },
+ "email": {
+ "format": "email",
+ "type": "string"
+ }
},
"required": [
- "field",
- "operator"
+ "email"
],
"type": "object"
},
- "SegmentV1": {
- "description": "A segment as exposed on the v1 API.",
+ "ListSubscribeResponse": {
+ "description": "Result of a list-subscribe call.",
"properties": {
- "condition": {
- "anyOf": [
- {
- "$ref": "#/components/schemas/FilterConditionV1"
+ "data": {
+ "properties": {
+ "confirmToken": {
+ "description": "Present only when the list has doubleOptIn enabled. Sendly does not send the confirmation email — deliver /api/lists/confirm-subscription?token= to the contact. Valid for 24 hours.",
+ "type": "string"
},
- {
- "type": "null"
+ "created": {
+ "description": "True when the membership row did not exist before this call.",
+ "type": "boolean"
+ },
+ "membershipId": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "previousStatus": {
+ "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.",
+ "enum": [
+ "PENDING",
+ "CONFIRMED",
+ "UNSUBSCRIBED",
+ null
+ ],
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "status": {
+ "enum": [
+ "PENDING",
+ "CONFIRMED",
+ "UNSUBSCRIBED"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "membershipId",
+ "status",
+ "created",
+ "previousStatus"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "ListUnsubscribe": {
+ "description": "Body for POST /api/lists/{id}/unsubscribe.",
+ "properties": {
+ "email": {
+ "format": "email",
+ "type": "string"
+ }
+ },
+ "required": [
+ "email"
+ ],
+ "type": "object"
+ },
+ "ListUnsubscribeResponse": {
+ "description": "Echoes the address that was unsubscribed.",
+ "properties": {
+ "data": {
+ "properties": {
+ "email": {
+ "format": "email",
+ "type": "string"
}
+ },
+ "required": [
+ "email"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "ListV1": {
+ "description": "A subscriber list as exposed on the v1 API.",
+ "properties": {
+ "confirmation_template_id": {
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
]
},
"created_at": {
@@ -2472,25 +3505,25 @@
"null"
]
},
+ "double_opt_in": {
+ "type": "boolean"
+ },
"id": {
"format": "uuid",
"type": "string"
},
"member_count": {
+ "description": "Memberships in ANY status, including PENDING and UNSUBSCRIBED ones.",
"type": "integer"
},
"name": {
"type": "string"
},
- "track_membership": {
- "type": "boolean"
- },
- "type": {
- "enum": [
- "DYNAMIC",
- "STATIC"
- ],
- "type": "string"
+ "redirect_url": {
+ "type": [
+ "string",
+ "null"
+ ]
},
"updated_at": {
"format": "date-time",
@@ -2501,42 +3534,49 @@
"id",
"name",
"description",
- "type",
- "condition",
- "track_membership",
+ "double_opt_in",
+ "confirmation_template_id",
+ "redirect_url",
"member_count",
"created_at",
"updated_at"
],
"type": "object"
},
- "SegmentV1Create": {
- "description": "Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`.",
+ "ListV1Create": {
+ "description": "Body for POST /api/v1/lists.",
"properties": {
- "condition": {
- "$ref": "#/components/schemas/FilterConditionV1"
+ "confirmation_template_id": {
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
},
"description": {
"maxLength": 500,
- "type": "string"
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "double_opt_in": {
+ "default": false,
+ "description": "Require the contact to confirm before the membership becomes CONFIRMED. Sendly does NOT send the confirmation email — subscribing returns a `confirm_token` and you deliver `/api/lists/confirm-subscription?token=` to the contact yourself.",
+ "type": "boolean"
},
"name": {
- "maxLength": 100,
+ "maxLength": 200,
"minLength": 1,
"type": "string"
},
- "track_membership": {
- "default": false,
- "description": "Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition.",
- "type": "boolean"
- },
- "type": {
- "default": "DYNAMIC",
- "enum": [
- "DYNAMIC",
- "STATIC"
- ],
- "type": "string"
+ "redirect_url": {
+ "description": "Where a confirmed contact is sent after following the confirmation link.",
+ "format": "uri",
+ "type": [
+ "string",
+ "null"
+ ]
}
},
"required": [
@@ -2544,8 +3584,8 @@
],
"type": "object"
},
- "SegmentV1Deleted": {
- "description": "Acknowledgement that a segment was deleted.",
+ "ListV1Deleted": {
+ "description": "Acknowledgement that a list was deleted.",
"properties": {
"deleted": {
"enum": [
@@ -2564,12 +3604,12 @@
],
"type": "object"
},
- "SegmentV1List": {
- "description": "Cursor-paginated list of segments.",
+ "ListV1List": {
+ "description": "Cursor-paginated list of subscriber lists.",
"properties": {
"data": {
"items": {
- "$ref": "#/components/schemas/SegmentV1"
+ "$ref": "#/components/schemas/ListV1"
},
"type": "array"
},
@@ -2591,630 +3631,606 @@
],
"type": "object"
},
- "SegmentV1Update": {
- "description": "Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment.",
+ "ListV1Update": {
+ "description": "Body for PATCH /api/v1/lists/{id}.",
"properties": {
- "condition": {
- "$ref": "#/components/schemas/FilterConditionV1"
+ "confirmation_template_id": {
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
},
"description": {
"maxLength": 500,
- "type": "string"
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "double_opt_in": {
+ "type": "boolean"
},
"name": {
- "maxLength": 100,
+ "maxLength": 200,
"minLength": 1,
"type": "string"
},
- "track_membership": {
- "type": "boolean"
+ "redirect_url": {
+ "format": "uri",
+ "type": [
+ "string",
+ "null"
+ ]
}
},
"type": "object"
},
- "SendEmail": {
- "description": "Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required.",
+ "Mailbox": {
+ "description": "A receiving mailbox on one of the project's verified domains.",
"properties": {
- "attachments": {
- "items": {
- "properties": {
- "content": {
- "minLength": 1,
- "type": "string"
- },
- "contentId": {
- "maxLength": 255,
- "minLength": 1,
- "pattern": "^[^<>\\r\\n]+$",
- "type": "string"
- },
- "contentType": {
- "maxLength": 255,
- "minLength": 1,
- "type": "string"
- },
- "disposition": {
- "default": "attachment",
- "enum": [
- "attachment",
- "inline"
- ],
- "type": "string"
- },
- "filename": {
- "maxLength": 255,
- "minLength": 1,
- "pattern": "^[^\\r\\n\"]+$",
- "type": "string"
- }
- },
- "required": [
- "filename",
- "content",
- "contentType"
- ],
- "type": "object"
- },
- "maxItems": 10,
- "type": "array"
+ "address": {
+ "description": "The full mailbox address, e.g. `support@superbooks.io`.",
+ "format": "email",
+ "type": "string"
},
- "bcc": {
- "items": {
- "format": "email",
- "type": "string"
- },
- "type": "array"
+ "createdAt": {
+ "format": "date-time",
+ "type": "string"
},
- "body": {
- "minLength": 1,
+ "displayName": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "domainId": {
+ "description": "The verified domain this mailbox lives on.",
+ "format": "uuid",
"type": "string"
},
- "cc": {
- "items": {
- "format": "email",
- "type": "string"
- },
- "type": "array"
+ "id": {
+ "format": "uuid",
+ "type": "string"
},
- "data": {
- "additionalProperties": {},
- "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
- "type": "object"
+ "quotaBytes": {
+ "description": "Always null. Mailbox quotas are not implemented — the value was never applied to the mail account — so this field reports the absence rather than a number nothing enforces.",
+ "type": [
+ "number",
+ "null"
+ ]
},
- "from": {
- "anyOf": [
- {
- "format": "email",
- "type": "string"
- },
- {
+ "status": {
+ "description": "`PROVISIONING` while the mail account is being created, `ACTIVE` once it can receive, `SUSPENDED` when receiving is paused, `FAILED` when provisioning did not complete. A `FAILED` mailbox can be re-created with the same address — the retry reclaims the row.",
+ "enum": [
+ "PROVISIONING",
+ "ACTIVE",
+ "SUSPENDED",
+ "FAILED"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "address",
+ "displayName",
+ "status",
+ "quotaBytes",
+ "domainId",
+ "createdAt"
+ ],
+ "type": "object"
+ },
+ "MailboxDetail": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Mailbox"
+ },
+ {
+ "properties": {
+ "settings": {
+ "description": "Host, port and username for connecting a mail client. The PASSWORD is not here and is never returned by this endpoint — create an app password for that.",
"properties": {
- "email": {
- "format": "email",
- "type": "string"
+ "imap": {
+ "properties": {
+ "host": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer"
+ },
+ "security": {
+ "description": "Transport security, e.g. `SSL/TLS`.",
+ "type": "string"
+ },
+ "username": {
+ "description": "The mailbox address — it is also the login.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "host",
+ "port",
+ "security",
+ "username"
+ ],
+ "type": "object"
},
- "name": {
- "pattern": "^[^\\r\\n]*$",
- "type": "string"
+ "smtp": {
+ "properties": {
+ "host": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer"
+ },
+ "security": {
+ "description": "Transport security, e.g. `SSL/TLS`.",
+ "type": "string"
+ },
+ "username": {
+ "description": "The mailbox address — it is also the login.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "host",
+ "port",
+ "security",
+ "username"
+ ],
+ "type": "object"
}
},
"required": [
- "email"
+ "imap",
+ "smtp"
],
"type": "object"
}
- ]
- },
- "headers": {
- "additionalProperties": {
- "maxLength": 998,
- "pattern": "^[^\\r\\n]*$",
- "type": "string"
},
+ "required": [
+ "settings"
+ ],
"type": "object"
- },
- "name": {
+ }
+ ],
+ "description": "A mailbox plus its IMAP/SMTP connection settings."
+ },
+ "Problem": {
+ "description": "RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface.",
+ "properties": {
+ "code": {
+ "description": "Machine-readable lowercase error code, e.g. `scope_missing`.",
"type": "string"
},
- "reply": {
- "format": "email",
+ "detail": {
+ "description": "Explanation specific to this occurrence.",
"type": "string"
},
- "subject": {
- "maxLength": 998,
- "minLength": 1,
- "pattern": "^[^\\r\\n]*$",
- "type": "string"
- },
- "subscribed": {
- "type": "boolean"
- },
- "tags": {
+ "errors": {
+ "description": "Field-level failures. Present on 422 `validation_error` responses.",
"items": {
- "maxLength": 64,
- "minLength": 1,
- "pattern": "^[a-zA-Z0-9_-]+$",
- "type": "string"
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "pointer": {
+ "description": "RFC 6901 JSON Pointer to the offending field.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "pointer",
+ "code",
+ "message"
+ ],
+ "type": "object"
},
- "maxItems": 10,
"type": "array"
},
- "template": {
- "format": "uuid",
+ "instance": {
+ "description": "Request path the failure occurred on.",
"type": "string"
},
- "to": {
- "anyOf": [
- {
- "format": "email",
- "type": "string"
- },
- {
- "properties": {
- "email": {
- "format": "email",
- "type": "string"
- },
- "name": {
- "type": "string"
- }
- },
- "required": [
- "email"
- ],
- "type": "object"
- },
- {
- "items": {
- "anyOf": [
- {
- "format": "email",
- "type": "string"
- },
- {
- "properties": {
- "email": {
- "format": "email",
- "type": "string"
- },
- "name": {
- "type": "string"
- }
- },
- "required": [
- "email"
- ],
- "type": "object"
- }
- ]
- },
- "type": "array"
- }
- ]
+ "request_id": {
+ "description": "Correlation id — quote it in support requests.",
+ "type": "string"
+ },
+ "status": {
+ "description": "HTTP status code, repeated in the body.",
+ "type": "integer"
+ },
+ "title": {
+ "description": "Short, stable summary — the same for every occurrence of a `type`.",
+ "type": "string"
+ },
+ "type": {
+ "description": "Dereferenceable URI identifying the error class, anchored on the docs errors page.",
+ "format": "uri",
+ "type": "string"
}
},
"required": [
- "to"
+ "type",
+ "title",
+ "status",
+ "code"
],
"type": "object"
},
- "SendEmailData": {
- "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`.",
+ "ProjectRecord": {
"properties": {
- "emails": {
- "items": {
- "$ref": "#/components/schemas/SendEmailRecipientResult"
- },
- "type": "array"
+ "billingLimitCampaigns": {
+ "type": [
+ "integer",
+ "null"
+ ]
},
- "timestamp": {
+ "billingLimitInbound": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "billingLimitTransactional": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "billingLimitWorkflows": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "createdAt": {
"description": "ISO 8601 datetime string",
"format": "date-time",
"type": "string"
- }
- },
- "required": [
- "emails",
- "timestamp"
- ],
- "type": "object"
- },
- "SendEmailRecipientResult": {
- "description": "Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient.",
- "properties": {
- "contact": {
- "properties": {
- "email": {
- "format": "email",
- "type": "string"
- },
- "id": {
- "format": "uuid",
- "type": "string"
- }
- },
- "required": [
- "id",
- "email"
- ],
- "type": "object"
},
- "email": {
+ "disabled": {
+ "type": "boolean"
+ },
+ "disabledReason": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "id": {
"format": "uuid",
"type": "string"
+ },
+ "language": {
+ "description": "ISO 639-1 code for customer-facing content.",
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "organizationId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sandboxHandle": {
+ "description": "Local-part of the sandbox quick-start sender; null until first derived.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sesRegion": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stripeCustomerId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stripeSubscriptionId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "tracking": {
+ "enum": [
+ "ENABLED",
+ "DISABLED",
+ "MARKETING_ONLY"
+ ],
+ "type": "string"
+ },
+ "updatedAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": "string"
}
},
"required": [
- "contact",
- "email"
+ "id",
+ "name",
+ "disabled",
+ "disabledReason",
+ "sandboxHandle",
+ "stripeCustomerId",
+ "stripeSubscriptionId",
+ "billingLimitWorkflows",
+ "billingLimitCampaigns",
+ "billingLimitTransactional",
+ "billingLimitInbound",
+ "tracking",
+ "sesRegion",
+ "language",
+ "organizationId",
+ "createdAt",
+ "updatedAt"
],
"type": "object"
},
- "SendEmailResponse": {
- "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.",
+ "ProjectV1": {
+ "description": "The project the presented credential is scoped to.",
"properties": {
- "data": {
- "$ref": "#/components/schemas/SendEmailData"
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
},
- "success": {
- "enum": [
- true
- ],
+ "disabled": {
+ "description": "A disabled project sends nothing; every send is refused.",
"type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
- },
- "SendEmailV1": {
- "description": "Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient.",
- "properties": {
- "attachments": {
- "items": {
- "properties": {
- "content": {
- "minLength": 1,
- "type": "string"
- },
- "contentId": {
- "maxLength": 255,
- "minLength": 1,
- "pattern": "^[^<>\\r\\n]+$",
- "type": "string"
- },
- "contentType": {
- "maxLength": 255,
- "minLength": 1,
- "type": "string"
- },
- "disposition": {
- "default": "attachment",
- "enum": [
- "attachment",
- "inline"
- ],
- "type": "string"
- },
- "filename": {
- "maxLength": 255,
- "minLength": 1,
- "pattern": "^[^\\r\\n\"]+$",
- "type": "string"
- }
- },
- "required": [
- "filename",
- "content",
- "contentType"
- ],
- "type": "object"
- },
- "maxItems": 10,
- "type": "array"
- },
- "bcc": {
- "items": {
- "format": "email",
- "type": "string"
- },
- "type": "array"
},
- "body": {
- "minLength": 1,
- "type": "string"
- },
- "cc": {
- "items": {
- "format": "email",
- "type": "string"
- },
- "type": "array"
- },
- "data": {
- "additionalProperties": {},
- "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
- "type": "object"
- },
- "from": {
- "anyOf": [
- {
- "format": "email",
- "type": "string"
- },
- {
- "properties": {
- "email": {
- "format": "email",
- "type": "string"
- },
- "name": {
- "pattern": "^[^\\r\\n]*$",
- "type": "string"
- }
- },
- "required": [
- "email"
- ],
- "type": "object"
- }
- ]
- },
- "headers": {
- "additionalProperties": {
- "maxLength": 998,
- "pattern": "^[^\\r\\n]*$",
- "type": "string"
- },
- "type": "object"
- },
- "name": {
+ "id": {
+ "format": "uuid",
"type": "string"
},
- "reply": {
- "format": "email",
+ "language": {
+ "description": "ISO 639-1 code for customer-facing content.",
"type": "string"
},
- "subject": {
- "maxLength": 998,
- "minLength": 1,
- "pattern": "^[^\\r\\n]*$",
+ "name": {
"type": "string"
},
- "subscribed": {
- "type": "boolean"
- },
- "tags": {
- "items": {
- "maxLength": 64,
- "minLength": 1,
- "pattern": "^[a-zA-Z0-9_-]+$",
- "type": "string"
- },
- "maxItems": 10,
- "type": "array"
+ "sandbox_address": {
+ "description": "This project's quick-start sender, usable with no domain setup — but only to the project owner's own verified address, and under a daily cap. Null when none can be derived.",
+ "type": [
+ "string",
+ "null"
+ ]
},
- "template": {
- "format": "uuid",
- "type": "string"
+ "ses_region": {
+ "description": "Locked once the first domain is added.",
+ "type": [
+ "string",
+ "null"
+ ]
},
- "to": {
- "anyOf": [
- {
- "format": "email",
- "type": "string"
- },
- {
- "properties": {
- "email": {
- "format": "email",
- "type": "string"
- },
- "name": {
- "type": "string"
- }
- },
- "required": [
- "email"
- ],
- "type": "object"
- }
+ "tracking": {
+ "enum": [
+ "ENABLED",
+ "DISABLED",
+ "MARKETING_ONLY"
],
- "description": "The single recipient. Use `cc`/`bcc` to copy others on the same message."
+ "type": "string"
}
},
"required": [
- "to"
+ "id",
+ "name",
+ "disabled",
+ "sandbox_address",
+ "ses_region",
+ "tracking",
+ "language",
+ "created_at"
],
"type": "object"
},
- "SendTestEmailV1": {
- "description": "Body for POST /api/v1/emails/test. `subject` and `body` are required; `to` defaults to the project owner's verified email, and `from` is refused.",
+ "RecipientDomainStatsV1": {
+ "description": "Delivery outcomes for one recipient domain on one day.",
"properties": {
- "body": {
- "description": "HTML body. Merge tags are rendered as on any other send.",
- "minLength": 1,
- "type": "string"
+ "bounced": {
+ "type": "integer"
},
- "from": {
- "description": "NOT ACCEPTED. The sender is always this project's sandbox address, resolved server-side; naming one here is refused rather than ignored, so a request that expects a different sender never gets a success it would misread. Read `sandbox_address` from `GET /api/v1/projects` to learn the address, or `from` off this response.",
+ "complained": {
+ "type": "integer"
+ },
+ "computed_at": {
+ "description": "When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly.",
+ "format": "date-time",
"type": "string"
},
- "subject": {
- "maxLength": 998,
- "minLength": 1,
+ "day": {
+ "description": "The UTC day these counts cover, as `YYYY-MM-DD`.",
"type": "string"
},
- "to": {
- "description": "Where to send it. Optional — it defaults to the project owner's own verified account email, which is the only address a sandbox send may reach. Any other value is refused.",
- "format": "email",
+ "delivered": {
+ "type": "integer"
+ },
+ "domain": {
+ "description": "The recipient's domain, lowercased: the part after the `@`.",
"type": "string"
+ },
+ "opened": {
+ "type": "integer"
+ },
+ "sent": {
+ "type": "integer"
}
},
"required": [
- "subject",
- "body"
+ "domain",
+ "day",
+ "sent",
+ "delivered",
+ "bounced",
+ "complained",
+ "opened",
+ "computed_at"
],
"type": "object"
},
- "SuccessEmpty": {
- "description": "Bare success envelope with no payload.",
+ "RecipientDomainStatsV1List": {
+ "description": "Cursor-paginated recipient-domain rollup, newest day first.",
"properties": {
- "success": {
- "enum": [
- true
- ],
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/RecipientDomainStatsV1"
+ },
+ "type": "array"
+ },
+ "has_more": {
"type": "boolean"
+ },
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
+ "type": [
+ "string",
+ "null"
+ ]
}
},
"required": [
- "success"
+ "data",
+ "has_more",
+ "next_cursor"
],
"type": "object"
},
- "Suppression": {
- "description": "A single suppressed-email record.",
+ "SegmentContactV1": {
+ "description": "A contact belonging to a segment.",
"properties": {
- "createdAt": {
- "description": "ISO 8601 datetime string",
+ "created_at": {
"format": "date-time",
"type": "string"
},
- "email": {
- "format": "email",
- "type": "string"
+ "custom_fields": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": "object"
},
- "id": {
- "format": "uuid",
+ "email": {
"type": "string"
},
- "projectId": {
+ "id": {
"format": "uuid",
"type": "string"
},
- "reason": {
- "enum": [
- "HARD_BOUNCE",
- "COMPLAINT",
- "MANUAL",
- "UNSUBSCRIBE"
- ],
- "type": "string"
- },
- "source": {
- "enum": [
- "SES_WEBHOOK",
- "API",
- "DASHBOARD"
- ],
- "type": "string"
+ "subscribed": {
+ "type": "boolean"
}
},
"required": [
"id",
- "projectId",
"email",
- "reason",
- "source",
- "createdAt"
- ],
- "type": "object"
- },
- "SuppressionCheckResponse": {
- "description": "Result of GET /api/suppression/{email} — whether the address is suppressed.",
- "properties": {
- "createdAt": {
- "description": "ISO 8601 datetime string",
- "format": "date-time",
- "type": "string"
- },
- "reason": {
- "enum": [
- "HARD_BOUNCE",
- "COMPLAINT",
- "MANUAL",
- "UNSUBSCRIBE"
- ],
- "type": "string"
- },
- "source": {
- "enum": [
- "SES_WEBHOOK",
- "API",
- "DASHBOARD"
- ],
- "type": "string"
- },
- "suppressed": {
- "type": "boolean"
- }
- },
- "required": [
- "suppressed"
+ "subscribed",
+ "custom_fields",
+ "created_at"
],
"type": "object"
},
- "SuppressionListResponse": {
- "description": "Cursor-paginated list of suppressions.",
+ "SegmentContactV1List": {
+ "description": "Cursor-paginated list of the contacts belonging to a segment.",
"properties": {
- "cursor": {
- "type": [
- "string",
- "null"
- ]
- },
"data": {
"items": {
- "$ref": "#/components/schemas/Suppression"
+ "$ref": "#/components/schemas/SegmentContactV1"
},
"type": "array"
},
- "hasMore": {
+ "has_more": {
"type": "boolean"
},
- "nextCursor": {
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
"type": [
"string",
"null"
]
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
}
},
"required": [
- "success",
- "data"
+ "data",
+ "has_more",
+ "next_cursor"
],
"type": "object"
},
- "Template": {
- "description": "A reusable email template.",
+ "SegmentFilterV1": {
+ "description": "One comparison. `field` addresses a contact column (`email`, `createdAt`, …) or a `customFields.` path. `value` is whatever the operator compares against — its JSON type follows the field, and it is omitted entirely for the presence operators (`exists`, `notExists`). `unit` applies only to the relative-time operators (`within`, `olderThan`, and the `triggered*` family).",
"properties": {
- "body": {
+ "field": {
+ "minLength": 1,
"type": "string"
},
- "createdAt": {
- "description": "ISO 8601 datetime string",
- "format": "date-time",
+ "operator": {
+ "enum": [
+ "equals",
+ "notEquals",
+ "contains",
+ "notContains",
+ "greaterThan",
+ "lessThan",
+ "greaterThanOrEqual",
+ "lessThanOrEqual",
+ "exists",
+ "notExists",
+ "within",
+ "olderThan",
+ "triggered",
+ "triggeredWithin",
+ "triggeredOlderThan",
+ "notTriggered",
+ "notTriggeredWithin",
+ "isMemberOf"
+ ],
"type": "string"
},
- "description": {
- "type": [
- "string",
- "null"
+ "unit": {
+ "enum": [
+ "days",
+ "hours",
+ "minutes"
+ ],
+ "type": "string"
+ },
+ "value": {}
+ },
+ "required": [
+ "field",
+ "operator"
+ ],
+ "type": "object"
+ },
+ "SegmentV1": {
+ "description": "A segment as exposed on the v1 API.",
+ "properties": {
+ "condition": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/FilterConditionV1"
+ },
+ {
+ "type": "null"
+ }
]
},
- "from": {
- "format": "email",
+ "created_at": {
+ "format": "date-time",
"type": "string"
},
- "fromName": {
+ "description": {
"type": [
"string",
"null"
@@ -3224,443 +4240,386 @@
"format": "uuid",
"type": "string"
},
- "name": {
- "type": "string"
+ "member_count": {
+ "type": "integer"
},
- "projectId": {
- "format": "uuid",
+ "name": {
"type": "string"
},
- "replyTo": {
- "format": "email",
- "type": [
- "string",
- "null"
- ]
- },
- "subject": {
- "type": "string"
+ "track_membership": {
+ "type": "boolean"
},
"type": {
"enum": [
- "MARKETING",
- "TRANSACTIONAL",
- "HEADLESS"
+ "DYNAMIC",
+ "STATIC"
],
"type": "string"
},
- "updatedAt": {
- "description": "ISO 8601 datetime string",
+ "updated_at": {
"format": "date-time",
"type": "string"
}
},
"required": [
"id",
- "projectId",
"name",
- "subject",
- "body",
- "from",
+ "description",
"type",
- "createdAt",
- "updatedAt"
+ "condition",
+ "track_membership",
+ "member_count",
+ "created_at",
+ "updated_at"
],
"type": "object"
},
- "TemplateListResponse": {
- "description": "Cursor-paginated list of templates.",
+ "SegmentV1Create": {
+ "description": "Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`.",
"properties": {
- "data": {
- "properties": {
- "cursor": {
- "description": "Cursor for the next page; omitted on the last page.",
- "type": "string"
- },
- "data": {
- "items": {
- "$ref": "#/components/schemas/Template"
- },
- "type": "array"
- },
- "hasMore": {
- "type": "boolean"
- },
- "total": {
- "type": "integer"
- }
- },
- "required": [
- "data",
- "total",
- "hasMore"
- ],
- "type": "object"
+ "condition": {
+ "$ref": "#/components/schemas/FilterConditionV1"
},
- "success": {
+ "description": {
+ "maxLength": 500,
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "track_membership": {
+ "default": false,
+ "description": "Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition.",
+ "type": "boolean"
+ },
+ "type": {
+ "default": "DYNAMIC",
"enum": [
- true
+ "DYNAMIC",
+ "STATIC"
],
- "type": "boolean"
+ "type": "string"
}
},
"required": [
- "success",
- "data"
+ "name"
],
"type": "object"
},
- "TrackEvent": {
- "description": "Body for POST /api/track — record a custom event for a contact.",
+ "SegmentV1Deleted": {
+ "description": "Acknowledgement that a segment was deleted.",
"properties": {
- "data": {
- "additionalProperties": {},
- "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
- "type": "object"
- },
- "email": {
- "format": "email",
- "type": "string"
+ "deleted": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
},
- "event": {
- "minLength": 1,
+ "id": {
+ "format": "uuid",
"type": "string"
- },
- "subscribed": {
- "type": "boolean"
}
},
"required": [
- "event",
- "email"
+ "id",
+ "deleted"
],
"type": "object"
},
- "TrackEventResponse": {
- "description": "Response from POST /api/track.",
+ "SegmentV1List": {
+ "description": "Cursor-paginated list of segments.",
"properties": {
"data": {
- "properties": {
- "contact": {
- "format": "uuid",
- "type": "string"
- },
- "event": {
- "format": "uuid",
- "type": "string"
- },
- "timestamp": {
- "description": "ISO 8601 datetime string",
- "format": "date-time",
- "type": "string"
- }
+ "items": {
+ "$ref": "#/components/schemas/SegmentV1"
},
- "required": [
- "contact",
- "event",
- "timestamp"
- ],
- "type": "object"
+ "type": "array"
},
- "success": {
- "enum": [
- true
- ],
+ "has_more": {
"type": "boolean"
+ },
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
+ "type": [
+ "string",
+ "null"
+ ]
}
},
"required": [
- "success",
- "data"
+ "data",
+ "has_more",
+ "next_cursor"
],
"type": "object"
},
- "UpdateContactBody": {
- "description": "Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses.",
+ "SegmentV1Update": {
+ "description": "Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment.",
"properties": {
- "customFields": {
- "additionalProperties": {},
- "type": "object"
+ "condition": {
+ "$ref": "#/components/schemas/FilterConditionV1"
},
- "subscribed": {
+ "description": {
+ "maxLength": 500,
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "track_membership": {
"type": "boolean"
}
},
"type": "object"
},
- "UpdateTemplate": {
- "description": "Body for PATCH /api/templates/{id}.",
+ "SendEmail": {
+ "description": "Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required.",
"properties": {
+ "attachments": {
+ "items": {
+ "properties": {
+ "content": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "contentId": {
+ "maxLength": 255,
+ "minLength": 1,
+ "pattern": "^[^<>\\r\\n]+$",
+ "type": "string"
+ },
+ "contentType": {
+ "maxLength": 255,
+ "minLength": 1,
+ "type": "string"
+ },
+ "disposition": {
+ "default": "attachment",
+ "enum": [
+ "attachment",
+ "inline"
+ ],
+ "type": "string"
+ },
+ "filename": {
+ "maxLength": 255,
+ "minLength": 1,
+ "pattern": "^[^\\r\\n\"]+$",
+ "type": "string"
+ }
+ },
+ "required": [
+ "filename",
+ "content",
+ "contentType"
+ ],
+ "type": "object"
+ },
+ "maxItems": 10,
+ "type": "array"
+ },
+ "bcc": {
+ "items": {
+ "format": "email",
+ "type": "string"
+ },
+ "type": "array"
+ },
"body": {
"minLength": 1,
"type": "string"
},
- "description": {
- "maxLength": 500,
- "type": "string"
+ "cc": {
+ "items": {
+ "format": "email",
+ "type": "string"
+ },
+ "type": "array"
},
- "from": {
- "format": "email",
- "type": "string"
+ "data": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": "object"
},
- "fromName": {
- "maxLength": 100,
- "type": [
- "string",
- "null"
+ "from": {
+ "anyOf": [
+ {
+ "format": "email",
+ "type": "string"
+ },
+ {
+ "properties": {
+ "email": {
+ "format": "email",
+ "type": "string"
+ },
+ "name": {
+ "pattern": "^[^\\r\\n]*$",
+ "type": "string"
+ }
+ },
+ "required": [
+ "email"
+ ],
+ "type": "object"
+ }
]
},
+ "headers": {
+ "additionalProperties": {
+ "maxLength": 998,
+ "pattern": "^[^\\r\\n]*$",
+ "type": "string"
+ },
+ "type": "object"
+ },
"name": {
- "maxLength": 100,
- "minLength": 1,
"type": "string"
},
- "replyTo": {
+ "reply": {
"format": "email",
- "type": [
- "string",
- "null"
- ]
+ "type": "string"
},
"subject": {
+ "maxLength": 998,
"minLength": 1,
+ "pattern": "^[^\\r\\n]*$",
"type": "string"
},
- "type": {
- "enum": [
- "TRANSACTIONAL",
- "MARKETING",
- "HEADLESS"
- ],
- "type": "string"
- }
- },
- "type": "object"
- },
- "UpdateWebhook": {
- "description": "Body for PATCH /api/webhooks/{id}.",
- "properties": {
- "eventTypes": {
+ "subscribed": {
+ "type": "boolean"
+ },
+ "tags": {
"items": {
- "enum": [
- "email.sent",
- "email.delivered",
- "email.opened",
- "email.clicked",
- "email.bounced",
- "email.complained",
- "email.failed",
- "contact.created",
- "contact.unsubscribed",
- "contacts.bulk_created"
- ],
+ "maxLength": 64,
+ "minLength": 1,
+ "pattern": "^[a-zA-Z0-9_-]+$",
"type": "string"
},
- "minItems": 1,
+ "maxItems": 10,
"type": "array"
},
- "status": {
- "enum": [
- "ACTIVE",
- "PAUSED",
- "DISABLED"
- ],
+ "template": {
+ "format": "uuid",
"type": "string"
},
- "url": {
- "format": "uri",
- "type": "string"
- }
- },
- "type": "object"
- },
- "UsageV1": {
- "description": "Current email usage against the limits that are actually enforced.",
- "properties": {
- "daily": {
- "properties": {
- "emails_sent": {
- "description": "Today's sends. Null when the counter could not be read.",
- "type": [
- "integer",
- "null"
- ]
- },
- "limit": {
- "type": "integer"
- },
- "trust_tier": {
- "enum": [
- "NEW",
- "ESTABLISHED",
- "TRUSTED"
- ],
+ "to": {
+ "anyOf": [
+ {
+ "format": "email",
"type": "string"
- }
- },
- "required": [
- "emails_sent",
- "limit",
- "trust_tier"
- ],
- "type": "object"
- },
- "monthly": {
- "properties": {
- "categories": {
+ },
+ {
"properties": {
- "campaign": {
- "properties": {
- "emails_sent": {
- "type": "integer"
- },
- "limit": {
- "type": [
- "integer",
- "null"
- ]
- }
- },
- "required": [
- "emails_sent",
- "limit"
- ],
- "type": "object"
- },
- "inbound": {
- "properties": {
- "emails_sent": {
- "type": "integer"
- },
- "limit": {
- "type": [
- "integer",
- "null"
- ]
- }
- },
- "required": [
- "emails_sent",
- "limit"
- ],
- "type": "object"
- },
- "transactional": {
- "properties": {
- "emails_sent": {
- "type": "integer"
- },
- "limit": {
- "type": [
- "integer",
- "null"
- ]
- }
- },
- "required": [
- "emails_sent",
- "limit"
- ],
- "type": "object"
+ "email": {
+ "format": "email",
+ "type": "string"
},
- "workflow": {
- "properties": {
- "emails_sent": {
- "type": "integer"
- },
- "limit": {
- "type": [
- "integer",
- "null"
- ]
- }
- },
- "required": [
- "emails_sent",
- "limit"
- ],
- "type": "object"
+ "name": {
+ "type": "string"
}
},
"required": [
- "transactional",
- "campaign",
- "workflow",
- "inbound"
+ "email"
],
"type": "object"
},
- "emails_sent": {
- "type": "integer"
- },
- "limit": {
- "description": "Monthly cap on the total. Null when per-category limits govern instead.",
- "type": [
- "integer",
- "null"
- ]
+ {
+ "items": {
+ "anyOf": [
+ {
+ "format": "email",
+ "type": "string"
+ },
+ {
+ "properties": {
+ "email": {
+ "format": "email",
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "email"
+ ],
+ "type": "object"
+ }
+ ]
+ },
+ "type": "array"
}
- },
- "required": [
- "emails_sent",
- "limit",
- "categories"
- ],
- "type": "object"
- },
- "plan": {
- "description": "`custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`.",
- "enum": [
- "free",
- "pro",
- "custom"
- ],
- "type": "string"
+ ]
}
},
"required": [
- "plan",
- "monthly",
- "daily"
+ "to"
],
"type": "object"
},
- "VerifyEmail": {
- "description": "Body for POST /api/verify — validate email syntax, MX, disposable, etc.",
+ "SendEmailData": {
+ "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`.",
"properties": {
- "email": {
- "format": "email",
+ "emails": {
+ "items": {
+ "$ref": "#/components/schemas/SendEmailRecipientResult"
+ },
+ "type": "array"
+ },
+ "timestamp": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
"type": "string"
}
},
"required": [
- "email"
+ "emails",
+ "timestamp"
],
"type": "object"
},
- "VerifyEmailResponse": {
- "description": "Response from POST /api/verify — outcome of the syntax/MX/disposable check.",
+ "SendEmailRecipientResult": {
+ "description": "Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient.",
"properties": {
- "data": {
- "additionalProperties": {},
+ "contact": {
"properties": {
"email": {
"format": "email",
"type": "string"
},
- "reason": {
+ "id": {
+ "format": "uuid",
"type": "string"
- },
- "valid": {
- "type": "boolean"
}
},
"required": [
- "email",
- "valid"
+ "id",
+ "email"
],
"type": "object"
},
+ "email": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "contact",
+ "email"
+ ],
+ "type": "object"
+ },
+ "SendEmailResponse": {
+ "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.",
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/SendEmailData"
+ },
"success": {
"enum": [
true
@@ -3674,165 +4633,278 @@
],
"type": "object"
},
- "Webhook": {
- "description": "A user-managed outbound webhook.",
+ "SendEmailV1": {
+ "description": "Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient.",
"properties": {
- "consecutiveFailures": {
- "type": "integer"
- },
- "createdAt": {
- "description": "ISO 8601 datetime string",
- "format": "date-time",
- "type": "string"
- },
- "disabledAt": {
- "description": "ISO 8601 datetime string",
- "format": "date-time",
- "type": [
- "string",
- "null"
- ]
- },
- "eventTypes": {
+ "attachments": {
"items": {
- "enum": [
- "email.sent",
- "email.delivered",
- "email.opened",
- "email.clicked",
- "email.bounced",
- "email.complained",
- "email.failed",
- "contact.created",
- "contact.unsubscribed",
- "contacts.bulk_created"
+ "properties": {
+ "content": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "contentId": {
+ "maxLength": 255,
+ "minLength": 1,
+ "pattern": "^[^<>\\r\\n]+$",
+ "type": "string"
+ },
+ "contentType": {
+ "maxLength": 255,
+ "minLength": 1,
+ "type": "string"
+ },
+ "disposition": {
+ "default": "attachment",
+ "enum": [
+ "attachment",
+ "inline"
+ ],
+ "type": "string"
+ },
+ "filename": {
+ "maxLength": 255,
+ "minLength": 1,
+ "pattern": "^[^\\r\\n\"]+$",
+ "type": "string"
+ }
+ },
+ "required": [
+ "filename",
+ "content",
+ "contentType"
],
+ "type": "object"
+ },
+ "maxItems": 10,
+ "type": "array"
+ },
+ "bcc": {
+ "items": {
+ "format": "email",
"type": "string"
},
"type": "array"
},
- "id": {
- "format": "uuid",
+ "body": {
+ "minLength": 1,
"type": "string"
},
- "lastFour": {
+ "cc": {
+ "items": {
+ "format": "email",
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "data": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": "object"
+ },
+ "from": {
+ "anyOf": [
+ {
+ "format": "email",
+ "type": "string"
+ },
+ {
+ "properties": {
+ "email": {
+ "format": "email",
+ "type": "string"
+ },
+ "name": {
+ "pattern": "^[^\\r\\n]*$",
+ "type": "string"
+ }
+ },
+ "required": [
+ "email"
+ ],
+ "type": "object"
+ }
+ ]
+ },
+ "headers": {
+ "additionalProperties": {
+ "maxLength": 998,
+ "pattern": "^[^\\r\\n]*$",
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "name": {
"type": "string"
},
- "projectId": {
+ "reply": {
+ "format": "email",
+ "type": "string"
+ },
+ "subject": {
+ "maxLength": 998,
+ "minLength": 1,
+ "pattern": "^[^\\r\\n]*$",
+ "type": "string"
+ },
+ "subscribed": {
+ "type": "boolean"
+ },
+ "tags": {
+ "items": {
+ "maxLength": 64,
+ "minLength": 1,
+ "pattern": "^[a-zA-Z0-9_-]+$",
+ "type": "string"
+ },
+ "maxItems": 10,
+ "type": "array"
+ },
+ "template": {
"format": "uuid",
"type": "string"
},
- "status": {
- "enum": [
- "ACTIVE",
- "PAUSED",
- "DISABLED"
+ "to": {
+ "anyOf": [
+ {
+ "format": "email",
+ "type": "string"
+ },
+ {
+ "properties": {
+ "email": {
+ "format": "email",
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "email"
+ ],
+ "type": "object"
+ }
],
+ "description": "The single recipient. Use `cc`/`bcc` to copy others on the same message."
+ }
+ },
+ "required": [
+ "to"
+ ],
+ "type": "object"
+ },
+ "SendTestEmailV1": {
+ "description": "Body for POST /api/v1/emails/test. `subject` and `body` are required; `to` defaults to the project owner's verified email, and `from` is refused.",
+ "properties": {
+ "body": {
+ "description": "HTML body. Merge tags are rendered as on any other send.",
+ "minLength": 1,
"type": "string"
},
- "updatedAt": {
- "description": "ISO 8601 datetime string",
- "format": "date-time",
+ "from": {
+ "description": "NOT ACCEPTED. The sender is always this project's sandbox address, resolved server-side; naming one here is refused rather than ignored, so a request that expects a different sender never gets a success it would misread. Read `sandbox_address` from `GET /api/v1/projects` to learn the address, or `from` off this response.",
"type": "string"
},
- "url": {
- "format": "uri",
+ "subject": {
+ "maxLength": 998,
+ "minLength": 1,
+ "type": "string"
+ },
+ "to": {
+ "description": "Where to send it. Optional — it defaults to the project owner's own verified account email, which is the only address a sandbox send may reach. Any other value is refused.",
+ "format": "email",
"type": "string"
}
},
"required": [
- "id",
- "projectId",
- "url",
- "eventTypes",
- "status",
- "consecutiveFailures",
- "createdAt",
- "updatedAt"
+ "subject",
+ "body"
],
"type": "object"
},
- "WebhookCall": {
- "description": "An attempted webhook delivery.",
+ "SendingStream": {
+ "description": "Which traffic this identity carries. Omit to leave it unassigned, which lets it carry every stream — that is what every domain added before per-stream identities does.",
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING"
+ ],
+ "type": "string"
+ },
+ "Snippet": {
+ "description": "A reusable fragment of template markup.",
"properties": {
- "attempt": {
- "type": "integer"
+ "body": {
+ "description": "Template markup. Values it interpolates are escaped like any other.",
+ "type": "string"
},
"createdAt": {
"description": "ISO 8601 datetime string",
"format": "date-time",
"type": "string"
},
- "eventType": {
- "type": "string"
- },
- "id": {
- "format": "uuid",
- "type": "string"
- },
- "payload": {
- "additionalProperties": {},
- "type": "object"
- },
- "responseBody": {
+ "description": {
"type": [
"string",
"null"
]
},
- "responseStatus": {
- "type": [
- "integer",
- "null"
- ]
+ "id": {
+ "format": "uuid",
+ "type": "string"
},
- "status": {
- "enum": [
- "PENDING",
- "SUCCESS",
- "FAILED"
- ],
+ "name": {
+ "description": "The literal identifier a template includes with `{{> name}}`.",
"type": "string"
},
- "webhookId": {
+ "projectId": {
"format": "uuid",
"type": "string"
+ },
+ "updatedAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": "string"
}
},
"required": [
"id",
- "webhookId",
- "eventType",
- "payload",
- "status",
- "attempt",
- "createdAt"
+ "projectId",
+ "name",
+ "body",
+ "createdAt",
+ "updatedAt"
],
"type": "object"
},
- "WebhookCallsListResponse": {
- "description": "Cursor-paginated list of recent calls for a single webhook.",
+ "SnippetListResponse": {
+ "description": "Cursor-paginated list of snippets.",
"properties": {
- "cursor": {
- "type": [
- "string",
- "null"
- ]
- },
"data": {
- "items": {
- "$ref": "#/components/schemas/WebhookCall"
- },
- "type": "array"
- },
- "hasMore": {
- "type": "boolean"
- },
- "nextCursor": {
- "type": [
- "string",
- "null"
- ]
+ "properties": {
+ "cursor": {
+ "description": "Cursor for the next page; omitted on the last page.",
+ "type": "string"
+ },
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/Snippet"
+ },
+ "type": "array"
+ },
+ "hasMore": {
+ "type": "boolean"
+ },
+ "total": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "data",
+ "total",
+ "hasMore"
+ ],
+ "type": "object"
},
"success": {
"enum": [
@@ -3847,29 +4919,9 @@
],
"type": "object"
},
- "WebhookCreateResponse": {
- "description": "Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely.",
+ "SuccessEmpty": {
+ "description": "Bare success envelope with no payload.",
"properties": {
- "data": {
- "allOf": [
- {
- "$ref": "#/components/schemas/Webhook"
- },
- {
- "properties": {
- "secret": {
- "description": "Plaintext shared secret. Returned ONCE on create.",
- "type": "string"
- }
- },
- "required": [
- "secret"
- ],
- "type": "object"
- }
- ],
- "description": "A user-managed outbound webhook."
- },
"success": {
"enum": [
true
@@ -3878,189 +4930,258 @@
}
},
"required": [
- "success",
- "data"
+ "success"
],
"type": "object"
},
- "WebhookGetResponse": {
- "description": "Single webhook (no secret).",
+ "Suppression": {
+ "description": "A single suppressed-email record.",
"properties": {
- "data": {
- "$ref": "#/components/schemas/Webhook"
+ "createdAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": "string"
},
- "success": {
+ "email": {
+ "format": "email",
+ "type": "string"
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "projectId": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "reason": {
"enum": [
- true
+ "HARD_BOUNCE",
+ "COMPLAINT",
+ "MANUAL",
+ "UNSUBSCRIBE"
],
- "type": "boolean"
+ "type": "string"
+ },
+ "scope": {
+ "description": "How far the suppression reaches. `PROJECT` is every record this API creates or returns today.",
+ "enum": [
+ "PROJECT",
+ "GLOBAL"
+ ],
+ "type": "string"
+ },
+ "source": {
+ "enum": [
+ "SES_WEBHOOK",
+ "API",
+ "DASHBOARD"
+ ],
+ "type": "string"
}
},
"required": [
- "success",
- "data"
+ "id",
+ "projectId",
+ "email",
+ "reason",
+ "source",
+ "scope",
+ "createdAt"
],
"type": "object"
},
- "WebhookListResponse": {
- "description": "List of webhooks for the auth'd project.",
+ "SuppressionCheckResponse": {
+ "description": "Result of GET /api/suppression/{email} — whether the address is suppressed.",
"properties": {
- "data": {
- "items": {
- "$ref": "#/components/schemas/Webhook"
- },
- "type": "array"
+ "createdAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": "string"
},
- "success": {
+ "reason": {
"enum": [
- true
+ "HARD_BOUNCE",
+ "COMPLAINT",
+ "MANUAL",
+ "UNSUBSCRIBE"
+ ],
+ "type": "string"
+ },
+ "source": {
+ "enum": [
+ "SES_WEBHOOK",
+ "API",
+ "DASHBOARD"
],
+ "type": "string"
+ },
+ "suppressed": {
"type": "boolean"
}
},
"required": [
- "success",
- "data"
+ "suppressed"
],
"type": "object"
},
- "WebhookRotateSecretResponse": {
- "description": "Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once.",
+ "SuppressionListResponse": {
+ "description": "Cursor-paginated list of suppressions. NOTE: this route answers a bare body — there is no `{success, data}` envelope.",
"properties": {
- "data": {
- "properties": {
- "id": {
- "format": "uuid",
- "type": "string"
- },
- "secret": {
- "description": "New plaintext shared secret.",
- "type": "string"
- }
+ "items": {
+ "items": {
+ "$ref": "#/components/schemas/Suppression"
},
- "required": [
- "id",
- "secret"
- ],
- "type": "object"
+ "type": "array"
},
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
+ "nextCursor": {
+ "description": "Cursor for the next page, or `null` on the last page. Never omitted.",
+ "type": [
+ "string",
+ "null"
+ ]
}
},
"required": [
- "success",
- "data"
+ "items",
+ "nextCursor"
],
"type": "object"
},
- "WorkflowCreateV1": {
- "description": "Body for POST /api/v1/workflows.",
+ "SuppressionV1": {
+ "description": "A suppressed address as exposed on the v1 API.",
"properties": {
- "allow_reentry": {
- "type": "boolean"
- },
- "description": {
- "maxLength": 1000,
+ "created_at": {
+ "format": "date-time",
"type": "string"
},
- "enabled": {
- "description": "Workflows are created disabled. A workflow can only be enabled once every step is configured.",
- "type": "boolean"
+ "email": {
+ "type": "string"
},
- "event_name": {
- "description": "The custom event that starts this workflow, e.g. `user.signup`.",
- "maxLength": 200,
- "minLength": 1,
+ "reason": {
+ "enum": [
+ "HARD_BOUNCE",
+ "COMPLAINT",
+ "MANUAL",
+ "UNSUBSCRIBE"
+ ],
"type": "string"
},
- "name": {
- "maxLength": 200,
- "minLength": 1,
+ "source": {
"type": "string"
}
},
"required": [
- "name",
- "event_name"
+ "email",
+ "reason",
+ "source",
+ "created_at"
],
"type": "object"
},
- "WorkflowDeletedV1": {
- "description": "Confirmation that a workflow was deleted.",
+ "SuppressionV1Create": {
+ "description": "Body for POST /api/v1/suppressions.",
"properties": {
- "deleted": {
+ "email": {
+ "format": "email",
+ "type": "string"
+ },
+ "reason": {
+ "default": "MANUAL",
"enum": [
- true
+ "HARD_BOUNCE",
+ "COMPLAINT",
+ "MANUAL",
+ "UNSUBSCRIBE"
],
- "type": "boolean"
- },
- "id": {
- "format": "uuid",
"type": "string"
}
},
"required": [
- "id",
- "deleted"
+ "email"
],
"type": "object"
},
- "WorkflowExecutionStartV1": {
- "description": "Body for POST /api/v1/workflows/{id}/executions.",
+ "SuppressionV1Deleted": {
+ "description": "Acknowledgement that an address was un-suppressed.",
"properties": {
- "contact_id": {
- "description": "Contact to enter the workflow. Must belong to this project.",
- "format": "uuid",
- "type": "string"
+ "deleted": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
},
- "context": {
- "additionalProperties": {
- "additionalProperties": {},
- "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
- "type": [
- "string",
- "number",
- "boolean",
- "object",
- "array",
- "null"
- ]
- },
- "description": "Extra variables merged into the contact's data for this run.",
- "type": "object"
+ "email": {
+ "type": "string"
}
},
"required": [
- "contact_id"
+ "email",
+ "deleted"
],
"type": "object"
},
- "WorkflowExecutionV1": {
- "description": "One contact's run through a workflow.",
+ "SuppressionV1List": {
+ "description": "Cursor-paginated list of suppressed addresses.",
"properties": {
- "completed_at": {
- "format": "date-time",
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/SuppressionV1"
+ },
+ "type": "array"
+ },
+ "has_more": {
+ "type": "boolean"
+ },
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
"type": [
"string",
"null"
]
+ }
+ },
+ "required": [
+ "data",
+ "has_more",
+ "next_cursor"
+ ],
+ "type": "object"
+ },
+ "Template": {
+ "description": "A reusable email template.",
+ "properties": {
+ "body": {
+ "type": "string"
},
- "contact_id": {
- "format": "uuid",
+ "createdAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
"type": "string"
},
- "current_step_id": {
- "format": "uuid",
+ "currentVersion": {
+ "description": "Version counter, incremented by an update that changes the rendered content. A campaign records the version it sent, so this is how a caller tells 'the template changed since' from 'the template was renamed'.",
+ "type": "integer"
+ },
+ "description": {
"type": [
"string",
"null"
]
},
- "exit_reason": {
+ "emailCategory": {
+ "enum": [
+ "MARKETING",
+ "TRANSACTIONAL",
+ "SELF_MANAGED_UNSUBSCRIBE"
+ ],
+ "type": "string"
+ },
+ "from": {
+ "format": "email",
+ "type": "string"
+ },
+ "fromName": {
"type": [
"string",
"null"
@@ -4070,208 +5191,113 @@
"format": "uuid",
"type": "string"
},
- "started_at": {
- "format": "date-time",
- "type": "string"
- },
- "status": {
- "enum": [
- "RUNNING",
- "WAITING",
- "COMPLETED",
- "EXITED",
- "FAILED",
- "CANCELLED"
- ],
+ "name": {
"type": "string"
},
- "workflow_id": {
+ "projectId": {
"format": "uuid",
"type": "string"
- }
- },
- "required": [
- "id",
- "workflow_id",
- "contact_id",
- "status",
- "current_step_id",
- "exit_reason",
- "started_at",
- "completed_at"
- ],
- "type": "object"
- },
- "WorkflowExecutionV1List": {
- "description": "Cursor-paginated list of workflow executions, newest first.",
- "properties": {
- "data": {
- "items": {
- "$ref": "#/components/schemas/WorkflowExecutionV1"
- },
- "type": "array"
- },
- "has_more": {
- "type": "boolean"
},
- "next_cursor": {
- "description": "Pass as `after` to fetch the next page. `null` on the last page.",
+ "replyTo": {
+ "format": "email",
"type": [
"string",
"null"
]
+ },
+ "subject": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": "string"
}
},
"required": [
- "data",
- "has_more",
- "next_cursor"
+ "id",
+ "projectId",
+ "name",
+ "subject",
+ "body",
+ "from",
+ "emailCategory",
+ "currentVersion",
+ "createdAt",
+ "updatedAt"
],
"type": "object"
},
- "WorkflowStatsV1": {
- "description": "Execution, email and conversion totals for one workflow.",
+ "TemplateListResponse": {
+ "description": "Cursor-paginated list of templates.",
"properties": {
- "avg_duration_ms": {
- "type": [
- "number",
- "null"
- ]
- },
- "by_status": {
- "additionalProperties": {
- "type": "integer"
- },
- "description": "Execution counts keyed by status; a status with no executions is absent.",
- "type": "object"
- },
- "completion_rate": {
- "description": "Completed ÷ finished executions (0–1). Null until at least one execution has finished.",
- "type": [
- "number",
- "null"
- ]
- },
- "conversions": {
- "items": {
- "properties": {
- "count": {
- "type": "integer"
- },
- "event_name": {
- "type": "string"
- },
- "goal_id": {
- "format": "uuid",
- "type": "string"
- },
- "name": {
- "type": "string"
- }
- },
- "required": [
- "goal_id",
- "name",
- "event_name",
- "count"
- ],
- "type": "object"
- },
- "type": "array"
- },
- "emails": {
+ "data": {
"properties": {
- "clicked": {
- "type": "integer"
+ "cursor": {
+ "description": "Cursor for the next page; omitted on the last page.",
+ "type": "string"
},
- "opened": {
- "type": "integer"
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/Template"
+ },
+ "type": "array"
},
- "sent": {
+ "hasMore": {
+ "type": "boolean"
+ },
+ "total": {
"type": "integer"
}
},
"required": [
- "sent",
- "opened",
- "clicked"
+ "data",
+ "total",
+ "hasMore"
],
"type": "object"
},
- "total": {
- "type": "integer"
- },
- "workflow_id": {
- "format": "uuid",
- "type": "string"
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
}
},
"required": [
- "workflow_id",
- "total",
- "by_status",
- "completion_rate",
- "avg_duration_ms",
- "emails",
- "conversions"
+ "success",
+ "data"
],
"type": "object"
},
- "WorkflowUpdateV1": {
- "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.",
+ "TemplateV1": {
+ "description": "An email template as exposed on the v1 API.",
"properties": {
- "allow_reentry": {
- "type": "boolean"
- },
- "description": {
- "maxLength": 1000,
+ "body": {
"type": "string"
},
- "enabled": {
- "type": "boolean"
- },
- "event_name": {
- "maxLength": 200,
- "minLength": 1,
+ "created_at": {
+ "format": "date-time",
"type": "string"
},
- "max_executions_per_hour": {
- "description": "Per-workflow start rate cap. `null` removes the cap.",
- "exclusiveMinimum": 0,
+ "description": {
"type": [
- "integer",
+ "string",
"null"
]
},
- "name": {
- "maxLength": 200,
- "minLength": 1,
+ "email_category": {
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING",
+ "SELF_MANAGED_UNSUBSCRIBE"
+ ],
"type": "string"
- }
- },
- "type": "object"
- },
- "WorkflowV1": {
- "description": "An automation workflow as exposed on the v1 API.",
- "properties": {
- "allow_reentry": {
- "type": "boolean"
},
- "created_at": {
- "format": "date-time",
+ "from": {
"type": "string"
},
- "description": {
- "type": [
- "string",
- "null"
- ]
- },
- "enabled": {
- "type": "boolean"
- },
- "event_name": {
- "description": "Trigger event for `EVENT` workflows; null for the other trigger types.",
+ "from_name": {
"type": [
"string",
"null"
@@ -4281,21 +5307,16 @@
"format": "uuid",
"type": "string"
},
- "max_executions_per_hour": {
+ "name": {
+ "type": "string"
+ },
+ "reply_to": {
"type": [
- "integer",
+ "string",
"null"
]
},
- "name": {
- "type": "string"
- },
- "trigger_type": {
- "enum": [
- "EVENT",
- "MANUAL",
- "SCHEDULE"
- ],
+ "subject": {
"type": "string"
},
"updated_at": {
@@ -4303,7 +5324,6 @@
"type": "string"
},
"version": {
- "description": "Incremented on every structural (step/transition) change.",
"type": "integer"
}
},
@@ -4311,23 +5331,104 @@
"id",
"name",
"description",
- "enabled",
- "trigger_type",
- "event_name",
- "allow_reentry",
- "max_executions_per_hour",
+ "subject",
+ "body",
+ "from",
+ "from_name",
+ "reply_to",
+ "email_category",
"version",
"created_at",
"updated_at"
],
"type": "object"
},
- "WorkflowV1List": {
- "description": "Cursor-paginated list of workflows.",
+ "TemplateV1Create": {
+ "description": "Body for POST /api/v1/templates.",
+ "properties": {
+ "body": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "description": {
+ "maxLength": 500,
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "email_category": {
+ "default": "MARKETING",
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING",
+ "SELF_MANAGED_UNSUBSCRIBE"
+ ],
+ "type": "string"
+ },
+ "from": {
+ "description": "Sender address. Its domain must be verified for this project.",
+ "format": "email",
+ "type": "string"
+ },
+ "from_name": {
+ "maxLength": 100,
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "reply_to": {
+ "format": "email",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "subject": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "subject",
+ "body",
+ "from"
+ ],
+ "type": "object"
+ },
+ "TemplateV1Deleted": {
+ "description": "Acknowledgement that a template was deleted.",
+ "properties": {
+ "deleted": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "deleted"
+ ],
+ "type": "object"
+ },
+ "TemplateV1List": {
+ "description": "Cursor-paginated list of templates.",
"properties": {
"data": {
"items": {
- "$ref": "#/components/schemas/WorkflowV1"
+ "$ref": "#/components/schemas/TemplateV1"
},
"type": "array"
},
@@ -4348,121 +5449,9942 @@
"next_cursor"
],
"type": "object"
- }
- },
- "securitySchemes": {
- "ApiKeyAuth": {
- "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 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`.",
- "scheme": "bearer",
- "type": "http"
},
- "OAuth2": {
- "description": "OAuth 2.1 with PKCE, for AI agents and other delegated clients (this is what the MCP endpoint at `/api/mcp` uses). Tokens are minted through the consent screen and carry ONLY the scopes the user ticked there, so an operation lists the single scope it requires and a token without it answers `403` with code `SCOPE_MISSING` — before any input is parsed. Unlike an API key, a delegated token reaches an operation only where the route itself declares a scope; every other route refuses it outright.",
- "flows": {
- "authorizationCode": {
- "authorizationUrl": "https://app.sendly.now/api/auth/oauth2/authorize",
- "scopes": {
- "analytics:read": "View your sending analytics and engagement metrics",
- "api-keys:read": "See which API keys exist, including what each one is allowed to do",
- "api-keys:write": "Create, rotate, and revoke API keys — these keep working even after you disconnect this app",
- "campaigns:read": "View your campaigns and their performance",
- "campaigns:send": "Send or schedule your campaigns to their audience",
- "campaigns:write": "Create, edit, and organize your campaigns",
- "contacts:read": "View your contacts and their custom fields",
- "contacts:write": "Create, update, and delete your contacts",
- "domains:read": "View your sending domains and their verification status",
- "domains:write": "Add and remove sending domains, and trigger verification",
- "emails:read": "View the emails you have sent and their delivery status",
- "emails:send": "Send emails from your verified domains",
- "emails:test": "Send test emails to your own address from the Sendly sandbox",
- "events:read": "View the custom events your application has recorded",
- "events:write": "Record custom events for your contacts",
- "mailboxes:read": "View the mailboxes on your domains and their settings",
- "mailboxes:write": "Create and delete mailboxes on your verified domains",
- "projects:read": "View your projects and their settings",
- "projects:write": "Create new projects on your account",
- "segments:read": "View your segments and who belongs to them",
- "segments:write": "Create, edit, and delete your segments",
- "suppression:read": "View the addresses on your suppression list",
- "suppression:write": "Add and remove addresses on your suppression list",
- "templates:read": "View your email templates",
- "templates:write": "Create, edit, and delete your email templates",
- "usage:read": "View your usage totals and billing limits",
- "webhooks:read": "View your webhook endpoints and their delivery history",
- "webhooks:write": "Create, edit, and delete your webhook endpoints",
- "workflows:read": "View your automation workflows and their runs",
- "workflows:write": "Create, edit, enable, and delete your automation workflows"
- },
- "tokenUrl": "https://app.sendly.now/api/auth/oauth2/token"
+ "TemplateV1Update": {
+ "description": "Body for PATCH /api/v1/templates/{id}.",
+ "properties": {
+ "body": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "description": {
+ "maxLength": 500,
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "email_category": {
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING",
+ "SELF_MANAGED_UNSUBSCRIBE"
+ ],
+ "type": "string"
+ },
+ "from": {
+ "format": "email",
+ "type": "string"
+ },
+ "from_name": {
+ "maxLength": 100,
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "reply_to": {
+ "format": "email",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "subject": {
+ "minLength": 1,
+ "type": "string"
}
},
- "type": "oauth2"
+ "type": "object"
},
- "SessionAuth": {
- "description": "BetterAuth session cookie. Used by the dashboard / browser clients. When present, the active project is taken from the `x-project-id` header.",
- "in": "cookie",
- "name": "better-auth.session_token",
- "type": "apiKey"
- }
- }
- },
- "info": {
- "contact": {
- "name": "Sendly Support",
- "url": "https://sendly.now"
- },
- "description": "Sendly's public REST API. Authenticate with a project API key as `Authorization: Bearer ` (`sk_*` for full access, `pk_*` for sending-only), with a BetterAuth session cookie, or — for AI agents and other delegated clients — with an OAuth 2.1 access token carrying the scopes its user approved. An operation lists the scope it requires under `OAuth2`; an operation that lists none refuses delegated tokens outright, whatever scopes they hold. 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.",
- "license": {
- "name": "AGPL-3.0",
- "url": "https://www.gnu.org/licenses/agpl-3.0.txt"
- },
- "title": "Sendly API",
- "version": "1.0.0"
- },
- "openapi": "3.1.0",
- "paths": {
- "/api/contacts": {
- "get": {
- "description": "Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`.\n\nRequires the `contacts:read` scope — View your contacts and their custom fields.",
- "operationId": "listContacts",
- "parameters": [
- {
- "in": "query",
- "name": "limit",
- "required": false,
- "schema": {
- "default": 50,
- "maximum": 100,
- "minimum": 1,
- "type": "integer"
- }
+ "TopicCreateV1": {
+ "properties": {
+ "default_opt_in": {
+ "type": "boolean"
+ },
+ "description": {
+ "maxLength": 1000,
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "key": {
+ "description": "Stable, URL-safe, unique within the project. This is what a preference form and an API caller name the topic by, so it must survive a rename of `name` — which is the whole reason it exists beside one.",
+ "maxLength": 64,
+ "minLength": 1,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
+ "type": "string"
},
+ "name": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "key",
+ "name"
+ ],
+ "type": "object"
+ },
+ "TopicListV1": {
+ "description": "One page of the subjects this project mails about.",
+ "properties": {
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/TopicV1"
+ },
+ "type": "array"
+ },
+ "has_more": {
+ "type": "boolean"
+ },
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "data",
+ "has_more",
+ "next_cursor"
+ ],
+ "type": "object"
+ },
+ "TopicSubscribeV1": {
+ "properties": {
+ "contact_id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "subscribed": {
+ "description": "True asks to subscribe, which starts a DOUBLE OPT-IN: the contact is parked at `pending` and a confirmation link is sent. There is no way to skip that from the API — a subscription an API caller asserts is not evidence the mailbox holder agreed, and treating it as consent is exactly what double opt-in exists to stop. False records an unsubscribe, which takes effect immediately.",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "contact_id",
+ "subscribed"
+ ],
+ "type": "object"
+ },
+ "TopicSubscriptionStatusV1": {
+ "description": "`subscribed`: mail them about this. `unsubscribed`: do not. `pending`: a confirmation link was sent and has NOT been clicked — never treat this as consent, which is the whole point of double opt-in.",
+ "enum": [
+ "pending",
+ "subscribed",
+ "unsubscribed"
+ ],
+ "type": "string"
+ },
+ "TopicSubscriptionV1": {
+ "properties": {
+ "confirmation_url": {
+ "description": "Present only when this call started a double opt-in. Sendly does NOT send the confirmation email — you do, from your own verified domain, because it is your relationship with the contact and your sending reputation. The subscription stays `pending`, and is NOT mailed, until someone opens this link.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "confirmed_at": {
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "contact_id": {
+ "type": "string"
+ },
+ "status": {
+ "$ref": "#/components/schemas/TopicSubscriptionStatusV1"
+ },
+ "topic_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "topic_id",
+ "contact_id",
+ "status",
+ "confirmed_at",
+ "confirmation_url"
+ ],
+ "type": "object"
+ },
+ "TopicUpdateV1": {
+ "description": "`key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them.",
+ "properties": {
+ "archived": {
+ "type": "boolean"
+ },
+ "default_opt_in": {
+ "type": "boolean"
+ },
+ "description": {
+ "maxLength": 1000,
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "TopicV1": {
+ "description": "One subject this project mails about.",
+ "properties": {
+ "archived": {
+ "description": "Hidden from the preference centre and from new sends, WITHOUT discarding the opt-outs recorded against it. There is no delete for the same reason: deleting a topic would delete the choices people made about it.",
+ "type": "boolean"
+ },
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "default_opt_in": {
+ "description": "What a contact with NO answer counts as. True is the honest default for a topic added after a list already exists: those contacts consented to hear from you, and inventing an opt-out they never asked for would silence mail they expect. False means the topic must be opted INTO, and absence means `not asked` rather than `no`.",
+ "type": "boolean"
+ },
+ "description": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "id": {
+ "type": "string"
+ },
+ "key": {
+ "description": "Stable, URL-safe, unique within the project. This is what a preference form and an API caller name the topic by, so it must survive a rename of `name` — which is the whole reason it exists beside one.",
+ "maxLength": 64,
+ "minLength": 1,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "subscribed_count": {
+ "description": "Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`.",
+ "type": "integer"
+ },
+ "unsubscribed_count": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "id",
+ "key",
+ "name",
+ "description",
+ "default_opt_in",
+ "archived",
+ "subscribed_count",
+ "unsubscribed_count",
+ "created_at"
+ ],
+ "type": "object"
+ },
+ "TrackEvent": {
+ "description": "Body for POST /api/track — record a custom event for a contact.",
+ "properties": {
+ "data": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": "object"
+ },
+ "email": {
+ "format": "email",
+ "type": "string"
+ },
+ "event": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "subscribed": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "event",
+ "email"
+ ],
+ "type": "object"
+ },
+ "TrackEventResponse": {
+ "description": "Response from POST /api/track.",
+ "properties": {
+ "data": {
+ "properties": {
+ "contact": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "event": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "timestamp": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": "string"
+ }
+ },
+ "required": [
+ "contact",
+ "event",
+ "timestamp"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "UpdateContactBody": {
+ "description": "Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses.",
+ "properties": {
+ "customFields": {
+ "additionalProperties": {},
+ "type": "object"
+ },
+ "subscribed": {
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
+ "UpdateSnippet": {
+ "description": "Body for PATCH /api/snippets/{id}.",
+ "properties": {
+ "body": {
+ "maxLength": 20000,
+ "minLength": 1,
+ "type": "string"
+ },
+ "description": {
+ "maxLength": 500,
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name": {
+ "pattern": "^[a-z][\\da-z_-]{0,63}$/i",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "UpdateTemplate": {
+ "description": "Body for PATCH /api/templates/{id}.",
+ "properties": {
+ "body": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "description": {
+ "maxLength": 500,
+ "type": "string"
+ },
+ "emailCategory": {
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING",
+ "SELF_MANAGED_UNSUBSCRIBE"
+ ],
+ "type": "string"
+ },
+ "from": {
+ "format": "email",
+ "type": "string"
+ },
+ "fromName": {
+ "maxLength": 100,
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "replyTo": {
+ "format": "email",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "subject": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "UpdateWebhook": {
+ "description": "Body for PATCH /api/webhooks/{id}.",
+ "properties": {
+ "eventTypes": {
+ "items": {
+ "enum": [
+ "email.sent",
+ "email.delivered",
+ "email.opened",
+ "email.clicked",
+ "email.bounced",
+ "email.complained",
+ "email.failed",
+ "contact.created",
+ "contact.unsubscribed",
+ "contacts.bulk_created"
+ ],
+ "type": "string"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "status": {
+ "enum": [
+ "ACTIVE",
+ "PAUSED",
+ "DISABLED"
+ ],
+ "type": "string"
+ },
+ "url": {
+ "format": "uri",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "UsageV1": {
+ "description": "Current email usage against the limits that are actually enforced.",
+ "properties": {
+ "daily": {
+ "properties": {
+ "emails_sent": {
+ "description": "Today's sends. Null when the counter could not be read.",
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "limit": {
+ "type": "integer"
+ },
+ "trust_tier": {
+ "enum": [
+ "NEW",
+ "ESTABLISHED",
+ "TRUSTED"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "emails_sent",
+ "limit",
+ "trust_tier"
+ ],
+ "type": "object"
+ },
+ "monthly": {
+ "properties": {
+ "categories": {
+ "properties": {
+ "campaign": {
+ "properties": {
+ "emails_sent": {
+ "type": "integer"
+ },
+ "limit": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "emails_sent",
+ "limit"
+ ],
+ "type": "object"
+ },
+ "inbound": {
+ "properties": {
+ "emails_sent": {
+ "type": "integer"
+ },
+ "limit": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "emails_sent",
+ "limit"
+ ],
+ "type": "object"
+ },
+ "transactional": {
+ "properties": {
+ "emails_sent": {
+ "type": "integer"
+ },
+ "limit": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "emails_sent",
+ "limit"
+ ],
+ "type": "object"
+ },
+ "workflow": {
+ "properties": {
+ "emails_sent": {
+ "type": "integer"
+ },
+ "limit": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "emails_sent",
+ "limit"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "transactional",
+ "campaign",
+ "workflow",
+ "inbound"
+ ],
+ "type": "object"
+ },
+ "emails_sent": {
+ "type": "integer"
+ },
+ "limit": {
+ "description": "Monthly cap on the total. Null when per-category limits govern instead.",
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "emails_sent",
+ "limit",
+ "categories"
+ ],
+ "type": "object"
+ },
+ "plan": {
+ "description": "`custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`.",
+ "enum": [
+ "free",
+ "pro",
+ "custom"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "plan",
+ "monthly",
+ "daily"
+ ],
+ "type": "object"
+ },
+ "VerifyEmail": {
+ "description": "Body for POST /api/verify — validate email syntax, MX, disposable, etc.",
+ "properties": {
+ "email": {
+ "format": "email",
+ "type": "string"
+ }
+ },
+ "required": [
+ "email"
+ ],
+ "type": "object"
+ },
+ "VerifyEmailResponse": {
+ "description": "Response from POST /api/verify — outcome of the syntax/MX/disposable check.",
+ "properties": {
+ "data": {
+ "additionalProperties": {},
+ "properties": {
+ "email": {
+ "format": "email",
+ "type": "string"
+ },
+ "reason": {
+ "type": "string"
+ },
+ "valid": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "email",
+ "valid"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "Webhook": {
+ "description": "A user-managed outbound webhook. Never carries a secret.",
+ "properties": {
+ "consecutiveFailures": {
+ "type": "integer"
+ },
+ "createdAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": "string"
+ },
+ "disabledAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "domains": {
+ "description": "Sending domains this endpoint is scoped to. Empty means every domain on the project.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "eventTypes": {
+ "items": {
+ "enum": [
+ "email.sent",
+ "email.delivered",
+ "email.opened",
+ "email.clicked",
+ "email.bounced",
+ "email.complained",
+ "email.failed",
+ "contact.created",
+ "contact.unsubscribed",
+ "contacts.bulk_created"
+ ],
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "previousSecretExpiresAt": {
+ "description": "While a rotation is in flight, when the OLD secret stops being accepted. `null` outside a rotation.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "projectId": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "ACTIVE",
+ "PAUSED",
+ "DISABLED"
+ ],
+ "type": "string"
+ },
+ "updatedAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": "string"
+ },
+ "url": {
+ "format": "uri",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "projectId",
+ "url",
+ "eventTypes",
+ "status",
+ "domains",
+ "consecutiveFailures",
+ "createdAt",
+ "updatedAt"
+ ],
+ "type": "object"
+ },
+ "WebhookCall": {
+ "description": "An attempted webhook delivery.",
+ "properties": {
+ "attempt": {
+ "type": "integer"
+ },
+ "createdAt": {
+ "description": "ISO 8601 datetime string",
+ "format": "date-time",
+ "type": "string"
+ },
+ "eventType": {
+ "type": "string"
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "payload": {
+ "additionalProperties": {},
+ "type": "object"
+ },
+ "responseBody": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "responseStatus": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "status": {
+ "enum": [
+ "PENDING",
+ "SUCCESS",
+ "FAILED"
+ ],
+ "type": "string"
+ },
+ "webhookId": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "webhookId",
+ "eventType",
+ "payload",
+ "status",
+ "attempt",
+ "createdAt"
+ ],
+ "type": "object"
+ },
+ "WebhookCallsListResponse": {
+ "description": "Cursor-paginated list of recent calls for a single webhook.",
+ "properties": {
+ "cursor": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/WebhookCall"
+ },
+ "type": "array"
+ },
+ "hasMore": {
+ "type": "boolean"
+ },
+ "nextCursor": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "WebhookCreateResponse": {
+ "description": "Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely.",
+ "properties": {
+ "data": {
+ "properties": {
+ "secret": {
+ "description": "Plaintext shared secret. Returned ONCE on create.",
+ "type": "string"
+ },
+ "webhook": {
+ "$ref": "#/components/schemas/Webhook"
+ }
+ },
+ "required": [
+ "webhook",
+ "secret"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "WebhookGetResponse": {
+ "description": "Single webhook (no secret).",
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Webhook"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "WebhookListResponse": {
+ "description": "List of webhooks for the auth'd project.",
+ "properties": {
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/Webhook"
+ },
+ "type": "array"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "WebhookRotateSecretResponse": {
+ "description": "Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once.",
+ "properties": {
+ "data": {
+ "properties": {
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "secret": {
+ "description": "New plaintext shared secret.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "secret"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ },
+ "WebhookV1": {
+ "description": "A webhook endpoint as exposed on the v1 API. The signing secret is NEVER on this shape — it is returned once, by create and by rotate, and no endpoint reads it back.",
+ "properties": {
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "event_types": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "ACTIVE",
+ "PAUSED",
+ "DISABLED"
+ ],
+ "type": "string"
+ },
+ "updated_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "url",
+ "event_types",
+ "status",
+ "created_at",
+ "updated_at"
+ ],
+ "type": "object"
+ },
+ "WebhookV1Create": {
+ "description": "Body for POST /api/v1/webhooks.",
+ "properties": {
+ "event_types": {
+ "items": {
+ "enum": [
+ "email.sent",
+ "email.delivered",
+ "email.opened",
+ "email.clicked",
+ "email.bounced",
+ "email.complained",
+ "email.failed",
+ "contact.created",
+ "contact.unsubscribed",
+ "contacts.bulk_created"
+ ],
+ "type": "string"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "url": {
+ "format": "uri",
+ "type": "string"
+ }
+ },
+ "required": [
+ "url",
+ "event_types"
+ ],
+ "type": "object"
+ },
+ "WebhookV1Created": {
+ "description": "A newly created webhook and its one-time signing secret.",
+ "properties": {
+ "secret": {
+ "description": "The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again.",
+ "type": "string"
+ },
+ "webhook": {
+ "$ref": "#/components/schemas/WebhookV1"
+ }
+ },
+ "required": [
+ "webhook",
+ "secret"
+ ],
+ "type": "object"
+ },
+ "WebhookV1Deleted": {
+ "description": "Acknowledgement that a webhook was deleted.",
+ "properties": {
+ "deleted": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "deleted"
+ ],
+ "type": "object"
+ },
+ "WebhookV1List": {
+ "description": "Cursor-paginated list of webhook endpoints.",
+ "properties": {
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/WebhookV1"
+ },
+ "type": "array"
+ },
+ "has_more": {
+ "type": "boolean"
+ },
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "data",
+ "has_more",
+ "next_cursor"
+ ],
+ "type": "object"
+ },
+ "WebhookV1SecretRotated": {
+ "description": "A freshly rotated signing secret, and the moment the outgoing one stops verifying.",
+ "properties": {
+ "previous_secret_expires_at": {
+ "description": "When the PREVIOUS secret stops verifying. Until then every delivery carries both signatures, so a consumer can redeploy its verifier without dropping an event.",
+ "format": "date-time",
+ "type": "string"
+ },
+ "secret": {
+ "description": "The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "secret",
+ "previous_secret_expires_at"
+ ],
+ "type": "object"
+ },
+ "WebhookV1Update": {
+ "description": "Body for PATCH /api/v1/webhooks/{id}.",
+ "properties": {
+ "event_types": {
+ "items": {
+ "enum": [
+ "email.sent",
+ "email.delivered",
+ "email.opened",
+ "email.clicked",
+ "email.bounced",
+ "email.complained",
+ "email.failed",
+ "contact.created",
+ "contact.unsubscribed",
+ "contacts.bulk_created"
+ ],
+ "type": "string"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "status": {
+ "enum": [
+ "ACTIVE",
+ "PAUSED",
+ "DISABLED"
+ ],
+ "type": "string"
+ },
+ "url": {
+ "format": "uri",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "WorkflowCloneV1": {
+ "description": "Body for `POST /api/v1/workflows/{id}/clone`.",
+ "properties": {
+ "name": {
+ "description": "Name for the copy. Defaults to `Copy of `.",
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "WorkflowConditionStepV1": {
+ "description": "Branches the run. Binary form: `field` + `operator` + `value`, whose outgoing transitions carry `{ \"branch\": \"yes\" }` / `{ \"branch\": \"no\" }`. Multi form: `mode: \"multi\"` + `field` + `branches`, whose transitions carry the branch id.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "branches": {
+ "items": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "id": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "operator": {
+ "enum": [
+ "equals",
+ "notEquals",
+ "contains",
+ "notContains",
+ "greaterThan",
+ "lessThan",
+ "greaterThanOrEqual",
+ "lessThanOrEqual",
+ "exists",
+ "notExists"
+ ],
+ "type": "string"
+ },
+ "value": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "operator"
+ ],
+ "type": "object"
+ },
+ "maxItems": 20,
+ "type": "array"
+ },
+ "field": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "mode": {
+ "enum": [
+ "multi"
+ ],
+ "type": "string"
+ },
+ "operator": {
+ "enum": [
+ "equals",
+ "notEquals",
+ "contains",
+ "notContains",
+ "greaterThan",
+ "lessThan",
+ "greaterThanOrEqual",
+ "lessThanOrEqual",
+ "exists",
+ "notExists"
+ ],
+ "type": "string"
+ },
+ "value": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
+ },
+ "id": {
+ "description": "Stable step id. On a graph write it is CHOSEN BY THE CALLER: send back the id you read to keep a step (and its run history), a fresh uuid to add one, and omit an id to delete that step.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "position": {
+ "$ref": "#/components/schemas/WorkflowStepPositionV1"
+ },
+ "template_id": {
+ "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "enum": [
+ "CONDITION"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "position",
+ "type",
+ "config"
+ ],
+ "type": "object"
+ },
+ "WorkflowCreateV1": {
+ "description": "Body for POST /api/v1/workflows.",
+ "properties": {
+ "allow_reentry": {
+ "type": "boolean"
+ },
+ "description": {
+ "maxLength": 1000,
+ "type": "string"
+ },
+ "enabled": {
+ "description": "Workflows are created disabled. A workflow can only be enabled once every step is configured.",
+ "type": "boolean"
+ },
+ "event_name": {
+ "description": "The custom event that starts this workflow, e.g. `user.signup`. Required for `EVENT` workflows (the default) and ignored for the other trigger types.",
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "interval_ms": {
+ "description": "For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour.",
+ "maximum": 2592000000,
+ "minimum": 60000,
+ "type": "integer"
+ },
+ "name": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "sequence": {
+ "description": "Optional LINEAR chain to create the workflow with, in run order. The trigger step is prepended and each step is wired to the next, so this cannot express branches — use `PUT /api/v1/workflows/{id}/graph` for those. Omit it to create a workflow that holds only its trigger step.",
+ "items": {
+ "$ref": "#/components/schemas/WorkflowSequenceStepV1"
+ },
+ "maxItems": 199,
+ "minItems": 1,
+ "type": "array"
+ },
+ "trigger_type": {
+ "$ref": "#/components/schemas/WorkflowTriggerTypeV1"
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "type": "object"
+ },
+ "WorkflowDelayStepV1": {
+ "description": "Pauses the run for `amount` × `unit`, up to 365 days.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "amount": {
+ "exclusiveMinimum": 0,
+ "type": "number"
+ },
+ "unit": {
+ "enum": [
+ "minutes",
+ "hours",
+ "days"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "id": {
+ "description": "Stable step id. On a graph write it is CHOSEN BY THE CALLER: send back the id you read to keep a step (and its run history), a fresh uuid to add one, and omit an id to delete that step.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "position": {
+ "$ref": "#/components/schemas/WorkflowStepPositionV1"
+ },
+ "template_id": {
+ "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "enum": [
+ "DELAY"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "position",
+ "type",
+ "config"
+ ],
+ "type": "object"
+ },
+ "WorkflowDeletedV1": {
+ "description": "Confirmation that a workflow was deleted.",
+ "properties": {
+ "deleted": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "deleted"
+ ],
+ "type": "object"
+ },
+ "WorkflowExecutionStartV1": {
+ "description": "Body for POST /api/v1/workflows/{id}/executions.",
+ "properties": {
+ "contact_id": {
+ "description": "Contact to enter the workflow. Must belong to this project.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "context": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "description": "Extra variables merged into the contact's data for this run.",
+ "type": "object"
+ }
+ },
+ "required": [
+ "contact_id"
+ ],
+ "type": "object"
+ },
+ "WorkflowExecutionV1": {
+ "description": "One contact's run through a workflow.",
+ "properties": {
+ "completed_at": {
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "contact_id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "current_step_id": {
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "exit_reason": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "started_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "RUNNING",
+ "WAITING",
+ "COMPLETED",
+ "EXITED",
+ "FAILED",
+ "CANCELLED"
+ ],
+ "type": "string"
+ },
+ "workflow_id": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "workflow_id",
+ "contact_id",
+ "status",
+ "current_step_id",
+ "exit_reason",
+ "started_at",
+ "completed_at"
+ ],
+ "type": "object"
+ },
+ "WorkflowExecutionV1List": {
+ "description": "Cursor-paginated list of workflow executions, newest first.",
+ "properties": {
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/WorkflowExecutionV1"
+ },
+ "type": "array"
+ },
+ "has_more": {
+ "type": "boolean"
+ },
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "data",
+ "has_more",
+ "next_cursor"
+ ],
+ "type": "object"
+ },
+ "WorkflowExitStepV1": {
+ "description": "Ends the run early and stamps `exit_reason`.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "reason": {
+ "maxLength": 200,
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "id": {
+ "description": "Stable step id. On a graph write it is CHOSEN BY THE CALLER: send back the id you read to keep a step (and its run history), a fresh uuid to add one, and omit an id to delete that step.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "position": {
+ "$ref": "#/components/schemas/WorkflowStepPositionV1"
+ },
+ "template_id": {
+ "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "enum": [
+ "EXIT"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "position",
+ "type",
+ "config"
+ ],
+ "type": "object"
+ },
+ "WorkflowGraphReplaceV1": {
+ "description": "Body for `PUT /api/v1/workflows/{id}/graph`. Replaces the whole graph: a step whose id you send is kept and updated, a fresh id is created, and a step you omit is deleted along with its run history. Refused with 409 while the workflow has running executions, because those runs are standing on the steps being replaced.",
+ "properties": {
+ "steps": {
+ "description": "The complete step set. Exactly one must be a `TRIGGER`.",
+ "items": {
+ "$ref": "#/components/schemas/WorkflowStepV1"
+ },
+ "maxItems": 200,
+ "minItems": 1,
+ "type": "array"
+ },
+ "transitions": {
+ "description": "The complete edge set. Every `from_step_id`/`to_step_id` must name a step in this same document, and a step may not point at itself.",
+ "items": {
+ "$ref": "#/components/schemas/WorkflowTransitionV1"
+ },
+ "maxItems": 400,
+ "type": "array"
+ }
+ },
+ "required": [
+ "steps",
+ "transitions"
+ ],
+ "type": "object"
+ },
+ "WorkflowGraphV1": {
+ "description": "A workflow's complete step graph. The response of `GET /api/v1/workflows/{id}/graph` is accepted verbatim by `PUT` on the same path.",
+ "properties": {
+ "steps": {
+ "items": {
+ "$ref": "#/components/schemas/WorkflowStepReadV1"
+ },
+ "type": "array"
+ },
+ "transitions": {
+ "items": {
+ "$ref": "#/components/schemas/WorkflowTransitionV1"
+ },
+ "type": "array"
+ },
+ "version": {
+ "description": "The workflow's version AFTER this read. Every structural change bumps it and writes a `workflow_versions` snapshot, so a changed number between two reads means somebody edited the graph.",
+ "type": "integer"
+ },
+ "workflow_id": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "workflow_id",
+ "version",
+ "steps",
+ "transitions"
+ ],
+ "type": "object"
+ },
+ "WorkflowSendAtOptimalTimeStepV1": {
+ "description": "Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "fallbackHour": {
+ "maximum": 23,
+ "minimum": 0,
+ "type": "integer"
+ },
+ "maxDelayHours": {
+ "exclusiveMinimum": 0,
+ "maximum": 168,
+ "type": "number"
+ },
+ "templateId": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "id": {
+ "description": "Stable step id. On a graph write it is CHOSEN BY THE CALLER: send back the id you read to keep a step (and its run history), a fresh uuid to add one, and omit an id to delete that step.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "position": {
+ "$ref": "#/components/schemas/WorkflowStepPositionV1"
+ },
+ "template_id": {
+ "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "enum": [
+ "SEND_AT_OPTIMAL_TIME"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "position",
+ "type",
+ "config"
+ ],
+ "type": "object"
+ },
+ "WorkflowSendEmailStepV1": {
+ "description": "Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "body": {
+ "type": "string"
+ },
+ "recipient": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "customEmail": {
+ "format": "email",
+ "type": "string"
+ },
+ "type": {
+ "enum": [
+ "CONTACT",
+ "CUSTOM"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "type": "object"
+ },
+ "subject": {
+ "maxLength": 1000,
+ "type": "string"
+ },
+ "templateId": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "id": {
+ "description": "Stable step id. On a graph write it is CHOSEN BY THE CALLER: send back the id you read to keep a step (and its run history), a fresh uuid to add one, and omit an id to delete that step.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "position": {
+ "$ref": "#/components/schemas/WorkflowStepPositionV1"
+ },
+ "template_id": {
+ "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "enum": [
+ "SEND_EMAIL"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "position",
+ "type",
+ "config"
+ ],
+ "type": "object"
+ },
+ "WorkflowSequenceStepTypeV1": {
+ "description": "A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server.",
+ "enum": [
+ "SEND_EMAIL",
+ "DELAY",
+ "WAIT_FOR_EVENT",
+ "CONDITION",
+ "EXIT",
+ "WEBHOOK",
+ "UPDATE_CONTACT",
+ "SEND_AT_OPTIMAL_TIME"
+ ],
+ "type": "string"
+ },
+ "WorkflowSequenceStepV1": {
+ "description": "One step of a linear workflow sequence.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "description": "Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects.",
+ "type": "object"
+ },
+ "name": {
+ "description": "Human-readable label, e.g. `Day 0: welcome`.",
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "template_id": {
+ "description": "For `SEND_EMAIL`: a template in this project.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "type": {
+ "$ref": "#/components/schemas/WorkflowSequenceStepTypeV1"
+ }
+ },
+ "required": [
+ "type",
+ "name",
+ "config"
+ ],
+ "type": "object"
+ },
+ "WorkflowStateChangeV1": {
+ "description": "The workflow after a pause or resume, with the number of runs the call stopped.",
+ "properties": {
+ "cancelled_executions": {
+ "description": "Runs stopped by this call. Always 0 for `resume`; on `pause` it is the number of `RUNNING`/`WAITING` executions that were cancelled, which is what makes pausing different from `PATCH { enabled: false }` (that only stops NEW runs starting).",
+ "type": "integer"
+ },
+ "workflow": {
+ "$ref": "#/components/schemas/WorkflowV1"
+ }
+ },
+ "required": [
+ "workflow",
+ "cancelled_executions"
+ ],
+ "type": "object"
+ },
+ "WorkflowStatsV1": {
+ "description": "Execution, email and conversion totals for one workflow.",
+ "properties": {
+ "avg_duration_ms": {
+ "type": [
+ "number",
+ "null"
+ ]
+ },
+ "by_status": {
+ "additionalProperties": {
+ "type": "integer"
+ },
+ "description": "Execution counts keyed by status; a status with no executions is absent.",
+ "type": "object"
+ },
+ "completion_rate": {
+ "description": "Completed ÷ finished executions (0–1). Null until at least one execution has finished.",
+ "type": [
+ "number",
+ "null"
+ ]
+ },
+ "conversions": {
+ "items": {
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "event_name": {
+ "type": "string"
+ },
+ "goal_id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "goal_id",
+ "name",
+ "event_name",
+ "count"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "emails": {
+ "properties": {
+ "clicked": {
+ "type": "integer"
+ },
+ "opened": {
+ "type": "integer"
+ },
+ "sent": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "sent",
+ "opened",
+ "clicked"
+ ],
+ "type": "object"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "string"
+ },
+ "step_count": {
+ "description": "Steps in the workflow's graph, trigger step included.",
+ "type": "integer"
+ },
+ "total": {
+ "type": "integer"
+ },
+ "trigger_type": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/WorkflowTriggerTypeV1"
+ },
+ {
+ "description": "What starts a workflow. `EVENT`: a custom event you record. `MANUAL`: only an explicit API call. `SCHEDULE`: a fixed repeating interval."
+ }
+ ]
+ },
+ "workflow_id": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "workflow_id",
+ "name",
+ "enabled",
+ "trigger_type",
+ "step_count",
+ "total",
+ "by_status",
+ "completion_rate",
+ "avg_duration_ms",
+ "emails",
+ "conversions"
+ ],
+ "type": "object"
+ },
+ "WorkflowStepPositionV1": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "description": "Where this step sits on the editor canvas.",
+ "properties": {
+ "x": {
+ "type": "number"
+ },
+ "y": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "x",
+ "y"
+ ],
+ "type": "object"
+ },
+ "WorkflowStepReadV1": {
+ "description": "One node of a workflow graph, as read.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "description": "The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase.",
+ "type": "object"
+ },
+ "id": {
+ "description": "Stable step id. On a graph write it is CHOSEN BY THE CALLER: send back the id you read to keep a step (and its run history), a fresh uuid to add one, and omit an id to delete that step.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "position": {
+ "$ref": "#/components/schemas/WorkflowStepPositionV1"
+ },
+ "template_id": {
+ "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "enum": [
+ "TRIGGER",
+ "SEND_EMAIL",
+ "DELAY",
+ "WAIT_FOR_EVENT",
+ "CONDITION",
+ "EXIT",
+ "WEBHOOK",
+ "UPDATE_CONTACT",
+ "SEND_AT_OPTIMAL_TIME"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "position",
+ "type",
+ "config"
+ ],
+ "type": "object"
+ },
+ "WorkflowStepV1": {
+ "description": "One node of a workflow graph.",
+ "discriminator": {
+ "mapping": {
+ "CONDITION": "#/components/schemas/WorkflowConditionStepV1",
+ "DELAY": "#/components/schemas/WorkflowDelayStepV1",
+ "EXIT": "#/components/schemas/WorkflowExitStepV1",
+ "SEND_AT_OPTIMAL_TIME": "#/components/schemas/WorkflowSendAtOptimalTimeStepV1",
+ "SEND_EMAIL": "#/components/schemas/WorkflowSendEmailStepV1",
+ "TRIGGER": "#/components/schemas/WorkflowTriggerStepV1",
+ "UPDATE_CONTACT": "#/components/schemas/WorkflowUpdateContactStepV1",
+ "WAIT_FOR_EVENT": "#/components/schemas/WorkflowWaitForEventStepV1",
+ "WEBHOOK": "#/components/schemas/WorkflowWebhookStepV1"
+ },
+ "propertyName": "type"
+ },
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/WorkflowTriggerStepV1"
+ },
+ {
+ "$ref": "#/components/schemas/WorkflowSendEmailStepV1"
+ },
+ {
+ "$ref": "#/components/schemas/WorkflowDelayStepV1"
+ },
+ {
+ "$ref": "#/components/schemas/WorkflowWaitForEventStepV1"
+ },
+ {
+ "$ref": "#/components/schemas/WorkflowConditionStepV1"
+ },
+ {
+ "$ref": "#/components/schemas/WorkflowExitStepV1"
+ },
+ {
+ "$ref": "#/components/schemas/WorkflowWebhookStepV1"
+ },
+ {
+ "$ref": "#/components/schemas/WorkflowUpdateContactStepV1"
+ },
+ {
+ "$ref": "#/components/schemas/WorkflowSendAtOptimalTimeStepV1"
+ }
+ ]
+ },
+ "WorkflowTransitionV1": {
+ "description": "One directed edge between two steps.",
+ "properties": {
+ "condition": {
+ "additionalProperties": {},
+ "description": "Null to always follow this edge. From a `CONDITION` step, `{ \"branch\": \"yes\" }`, `{ \"branch\": \"no\" }`, or `{ \"branch\": \"\" }` in the multi form.",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "from_step_id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "id": {
+ "description": "Caller-chosen on a write, exactly like a step id.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "priority": {
+ "description": "Evaluation order among the edges leaving one step; lowest first.",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "to_step_id": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "from_step_id",
+ "to_step_id",
+ "condition",
+ "priority"
+ ],
+ "type": "object"
+ },
+ "WorkflowTriggerStepV1": {
+ "description": "The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "eventName": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "intervalMs": {
+ "exclusiveMinimum": 0,
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "id": {
+ "description": "Stable step id. On a graph write it is CHOSEN BY THE CALLER: send back the id you read to keep a step (and its run history), a fresh uuid to add one, and omit an id to delete that step.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "position": {
+ "$ref": "#/components/schemas/WorkflowStepPositionV1"
+ },
+ "template_id": {
+ "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "enum": [
+ "TRIGGER"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "position",
+ "type",
+ "config"
+ ],
+ "type": "object"
+ },
+ "WorkflowTriggerTypeV1": {
+ "description": "What starts this workflow. Defaults to `EVENT`, which is the only kind the API created before this field existed. `EVENT` requires `event_name`; `SCHEDULE` uses `interval_ms`; `MANUAL` is started only by `POST /api/v1/workflows/{id}/executions`.",
+ "enum": [
+ "EVENT",
+ "MANUAL",
+ "SCHEDULE"
+ ],
+ "type": "string"
+ },
+ "WorkflowUpdateContactStepV1": {
+ "description": "Writes `updates` onto the contact, and optionally flips `subscribed`.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "subscribed": {
+ "type": "boolean"
+ },
+ "updates": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "id": {
+ "description": "Stable step id. On a graph write it is CHOSEN BY THE CALLER: send back the id you read to keep a step (and its run history), a fresh uuid to add one, and omit an id to delete that step.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "position": {
+ "$ref": "#/components/schemas/WorkflowStepPositionV1"
+ },
+ "template_id": {
+ "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "enum": [
+ "UPDATE_CONTACT"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "position",
+ "type",
+ "config"
+ ],
+ "type": "object"
+ },
+ "WorkflowUpdateV1": {
+ "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.",
+ "properties": {
+ "allow_reentry": {
+ "type": "boolean"
+ },
+ "description": {
+ "maxLength": 1000,
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "event_name": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "interval_ms": {
+ "description": "For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour.",
+ "maximum": 2592000000,
+ "minimum": 60000,
+ "type": "integer"
+ },
+ "max_executions_per_hour": {
+ "description": "Per-workflow start rate cap. `null` removes the cap.",
+ "exclusiveMinimum": 0,
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "name": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "sequence": {
+ "description": "Optional LINEAR chain that REPLACES every non-trigger step, in run order. Destructive: the existing steps are discarded and their ids do not survive, which is why omitting this field leaves the graph untouched. Branches need `PUT /api/v1/workflows/{id}/graph` instead.",
+ "items": {
+ "$ref": "#/components/schemas/WorkflowSequenceStepV1"
+ },
+ "maxItems": 199,
+ "minItems": 1,
+ "type": "array"
+ },
+ "trigger_type": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/WorkflowTriggerTypeV1"
+ },
+ {
+ "description": "What starts a workflow. `EVENT`: a custom event you record. `MANUAL`: only an explicit API call. `SCHEDULE`: a fixed repeating interval."
+ }
+ ]
+ }
+ },
+ "type": "object"
+ },
+ "WorkflowV1": {
+ "description": "An automation workflow as exposed on the v1 API.",
+ "properties": {
+ "allow_reentry": {
+ "type": "boolean"
+ },
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "description": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "event_name": {
+ "description": "Trigger event for `EVENT` workflows; null for the other trigger types.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "id": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "max_executions_per_hour": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "name": {
+ "type": "string"
+ },
+ "step_count": {
+ "description": "Steps in this workflow's graph, trigger step included — so a workflow that has only ever been created reports 1. Read `/graph` for the steps themselves.",
+ "type": "integer"
+ },
+ "trigger_type": {
+ "enum": [
+ "EVENT",
+ "MANUAL",
+ "SCHEDULE"
+ ],
+ "type": "string"
+ },
+ "updated_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "version": {
+ "description": "Incremented on every structural (step/transition) change.",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "description",
+ "enabled",
+ "trigger_type",
+ "event_name",
+ "allow_reentry",
+ "max_executions_per_hour",
+ "version",
+ "step_count",
+ "created_at",
+ "updated_at"
+ ],
+ "type": "object"
+ },
+ "WorkflowV1List": {
+ "description": "Cursor-paginated list of workflows.",
+ "properties": {
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/WorkflowV1"
+ },
+ "type": "array"
+ },
+ "has_more": {
+ "type": "boolean"
+ },
+ "next_cursor": {
+ "description": "Pass as `after` to fetch the next page. `null` on the last page.",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "data",
+ "has_more",
+ "next_cursor"
+ ],
+ "type": "object"
+ },
+ "WorkflowWaitForEventStepV1": {
+ "description": "Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "eventName": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "timeout": {
+ "exclusiveMinimum": 0,
+ "maximum": 31536000,
+ "type": "number"
+ }
+ },
+ "type": "object"
+ },
+ "id": {
+ "description": "Stable step id. On a graph write it is CHOSEN BY THE CALLER: send back the id you read to keep a step (and its run history), a fresh uuid to add one, and omit an id to delete that step.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "position": {
+ "$ref": "#/components/schemas/WorkflowStepPositionV1"
+ },
+ "template_id": {
+ "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "enum": [
+ "WAIT_FOR_EVENT"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "position",
+ "type",
+ "config"
+ ],
+ "type": "object"
+ },
+ "WorkflowWebhookStepV1": {
+ "description": "Calls an external URL. `url`, header values and the JSON body's string leaves are `{{variable}}`-interpolated from the contact and the run's variables.",
+ "properties": {
+ "config": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "properties": {
+ "body": {
+ "additionalProperties": {},
+ "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).",
+ "type": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "headers": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "method": {
+ "enum": [
+ "GET",
+ "POST",
+ "PUT",
+ "PATCH",
+ "DELETE"
+ ],
+ "type": "string"
+ },
+ "url": {
+ "format": "uri",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "id": {
+ "description": "Stable step id. On a graph write it is CHOSEN BY THE CALLER: send back the id you read to keep a step (and its run history), a fresh uuid to add one, and omit an id to delete that step.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "position": {
+ "$ref": "#/components/schemas/WorkflowStepPositionV1"
+ },
+ "template_id": {
+ "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type.",
+ "format": "uuid",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "enum": [
+ "WEBHOOK"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "position",
+ "type",
+ "config"
+ ],
+ "type": "object"
+ }
+ },
+ "securitySchemes": {
+ "ApiKeyAuth": {
+ "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 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`.",
+ "scheme": "bearer",
+ "type": "http"
+ },
+ "OAuth2": {
+ "description": "OAuth 2.1 with PKCE, for AI agents and other delegated clients (this is what the MCP endpoint at `/api/mcp` uses). Tokens are minted through the consent screen and carry ONLY the scopes the user ticked there, so an operation lists the single scope it requires and a token without it answers `403` with code `SCOPE_MISSING` — before any input is parsed. Unlike an API key, a delegated token reaches an operation only where the route itself declares a scope; every other route refuses it outright.",
+ "flows": {
+ "authorizationCode": {
+ "authorizationUrl": "https://app.sendly.now/api/auth/oauth2/authorize",
+ "scopes": {
+ "analytics:read": "View your sending analytics and engagement metrics",
+ "api-keys:read": "See which API keys exist, including what each one is allowed to do",
+ "api-keys:write": "Create, rotate, and revoke API keys — these keep working even after you disconnect this app",
+ "campaigns:read": "View your campaigns and their performance",
+ "campaigns:send": "Send or schedule your campaigns to their audience",
+ "campaigns:write": "Create, edit, and organize your campaigns",
+ "contacts:read": "View your contacts and their custom fields",
+ "contacts:write": "Create, update, and delete your contacts",
+ "deliverability:read": "Check why mail from one of your domains is not arriving",
+ "domains:read": "View your sending domains and their verification status",
+ "domains:write": "Add and remove sending domains, and trigger verification",
+ "emails:read": "View the emails you have sent and their delivery status",
+ "emails:send": "Send emails from your verified domains",
+ "emails:test": "Send test emails to your own address from the Sendly sandbox",
+ "events:read": "View the custom events your application has recorded",
+ "events:write": "Record custom events for your contacts",
+ "lists:read": "View your subscriber lists and who is on them",
+ "lists:write": "Create, rename, and delete your subscriber lists",
+ "mailboxes:read": "View the mailboxes on your domains and their settings",
+ "mailboxes:send": "Write and send new email from your hosted mailboxes, as that address",
+ "mailboxes:write": "Create and delete mailboxes on your verified domains",
+ "projects:read": "View your projects and their settings",
+ "projects:write": "Create new projects on your account",
+ "segments:read": "View your segments and who belongs to them",
+ "segments:write": "Create, edit, and delete your segments",
+ "suppression:read": "View the addresses on your suppression list",
+ "suppression:write": "Add and remove addresses on your suppression list",
+ "templates:read": "View your email templates",
+ "templates:write": "Create, edit, and delete your email templates",
+ "topics:read": "View the topics you mail about and who is subscribed to each",
+ "topics:write": "Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach",
+ "usage:read": "View your usage totals and billing limits",
+ "validation:read": "View your email validation runs and their results",
+ "validation:write": "Check whether email addresses can receive mail — this is billed per address",
+ "webhooks:read": "View your webhook endpoints and their delivery history",
+ "webhooks:write": "Create, edit, and delete your webhook endpoints",
+ "workflows:read": "View your automation workflows and their runs",
+ "workflows:write": "Create, edit, enable, and delete your automation workflows"
+ },
+ "tokenUrl": "https://app.sendly.now/api/auth/oauth2/token"
+ }
+ },
+ "type": "oauth2"
+ },
+ "SessionAuth": {
+ "description": "BetterAuth session cookie. Used by the dashboard / browser clients. When present, the active project is taken from the `x-project-id` header.",
+ "in": "cookie",
+ "name": "better-auth.session_token",
+ "type": "apiKey"
+ }
+ }
+ },
+ "info": {
+ "contact": {
+ "name": "Sendly Support",
+ "url": "https://sendly.now"
+ },
+ "description": "Sendly's public REST API. Authenticate with a project API key as `Authorization: Bearer ` (`sk_*` for full access, `pk_*` for sending-only), with a BetterAuth session cookie, or — for AI agents and other delegated clients — with an OAuth 2.1 access token carrying the scopes its user approved. An operation lists the scope it requires under `OAuth2`; an operation that lists none refuses delegated tokens outright, whatever scopes they hold. 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.",
+ "license": {
+ "name": "AGPL-3.0",
+ "url": "https://www.gnu.org/licenses/agpl-3.0.txt"
+ },
+ "title": "Sendly API",
+ "version": "1.0.0"
+ },
+ "openapi": "3.1.0",
+ "paths": {
+ "/api/contacts": {
+ "get": {
+ "description": "Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`.\n\nRequires the `contacts:read` scope — View your contacts and their custom fields.",
+ "operationId": "listContacts",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 50,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "cursor",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "search",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "subscribed",
+ "required": false,
+ "schema": {
+ "enum": [
+ "true",
+ "false"
+ ],
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ContactListResponse"
+ }
+ }
+ },
+ "description": "Contact list"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "contacts:read"
+ ]
+ }
+ ],
+ "summary": "List contacts",
+ "tags": [
+ "Contacts"
+ ]
+ },
+ "post": {
+ "description": "Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
+ "operationId": "createContact",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateContact"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Contact"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Contact created"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Email already exists for this project"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "contacts:write"
+ ]
+ }
+ ],
+ "summary": "Create a contact",
+ "tags": [
+ "Contacts"
+ ]
+ }
+ },
+ "/api/contacts/bulk": {
+ "delete": {
+ "description": "Delete up to 1000 contacts in one call. Provide either `ids` or `emails`.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
+ "operationId": "bulkDeleteContacts",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ContactBulkDeleteBody"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "properties": {
+ "deleted": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "deleted"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Bulk-delete result"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "contacts:write"
+ ]
+ }
+ ],
+ "summary": "Bulk-delete contacts",
+ "tags": [
+ "Contacts"
+ ]
+ },
+ "post": {
+ "description": "Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
+ "operationId": "bulkCreateContacts",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ContactBulkCreateBody"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "properties": {
+ "created": {
+ "type": "integer"
+ },
+ "errors": {
+ "items": {
+ "properties": {
+ "index": {
+ "type": "integer"
+ },
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "index",
+ "message"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "skipped": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "created",
+ "skipped",
+ "errors"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Bulk-create result"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "contacts:write"
+ ]
+ }
+ ],
+ "summary": "Bulk-create contacts",
+ "tags": [
+ "Contacts"
+ ]
+ }
+ },
+ "/api/contacts/upsert": {
+ "post": {
+ "description": "Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
+ "operationId": "upsertContact",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateContact"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Contact"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Contact created or updated"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "contacts:write"
+ ]
+ }
+ ],
+ "summary": "Create or update a contact by email",
+ "tags": [
+ "Contacts"
+ ]
+ }
+ },
+ "/api/contacts/{id}": {
+ "delete": {
+ "description": "Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content).\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
+ "operationId": "deleteContact",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IdResponse"
+ }
+ }
+ },
+ "description": "Contact deleted"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "contacts:write"
+ ]
+ }
+ ],
+ "summary": "Delete a contact",
+ "tags": [
+ "Contacts"
+ ]
+ },
+ "get": {
+ "description": "Requires the `contacts:read` scope — View your contacts and their custom fields.",
+ "operationId": "getContact",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Contact"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Contact"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "contacts:read"
+ ]
+ }
+ ],
+ "summary": "Get a contact",
+ "tags": [
+ "Contacts"
+ ]
+ },
+ "patch": {
+ "description": "Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
+ "operationId": "updateContact",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateContactBody"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Contact"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Updated contact"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "contacts:write"
+ ]
+ }
+ ],
+ "summary": "Update a contact",
+ "tags": [
+ "Contacts"
+ ]
+ }
+ },
+ "/api/domains": {
+ "get": {
+ "description": "List all domains for the authenticated project.\n\nRequires the `domains:read` scope — View your sending domains and their verification status.",
+ "operationId": "listDomains",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DomainListResponse"
+ }
+ }
+ },
+ "description": "Domain list"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:read"
+ ]
+ }
+ ],
+ "summary": "List sending domains",
+ "tags": [
+ "Domains"
+ ]
+ },
+ "post": {
+ "description": "Register a new domain with SES and persist its DKIM tokens.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
+ "operationId": "addDomain",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AddDomainBody"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Domain"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Domain added"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "502": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "AWS SES rejected the identity setup (unusable credentials, a missing IAM permission, or a refusal on Amazon's side). No domain row is written, and the request is not at fault — retrying it unchanged will not help."
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:write"
+ ]
+ }
+ ],
+ "summary": "Add a sending domain",
+ "tags": [
+ "Domains"
+ ]
+ }
+ },
+ "/api/domains/{id}": {
+ "delete": {
+ "description": "Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
+ "operationId": "deleteDomain",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SuccessEmpty"
+ }
+ }
+ },
+ "description": "Domain removed"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:write"
+ ]
+ }
+ ],
+ "summary": "Remove a sending domain",
+ "tags": [
+ "Domains"
+ ]
+ },
+ "get": {
+ "description": "Requires the `domains:read` scope — View your sending domains and their verification status.",
+ "operationId": "getDomain",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Domain"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Domain"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:read"
+ ]
+ }
+ ],
+ "summary": "Get a sending domain",
+ "tags": [
+ "Domains"
+ ]
+ },
+ "patch": {
+ "description": "Assign this identity to transactional or marketing traffic, make it the project's default for that stream, or give it the from-address a send on that stream uses when it names none.\n\nStreams are enforced, not labelled: once an identity is assigned, a send of the other kind from it is refused with 403. That is what keeps a campaign's complaint rate off the identity your password resets go out on. An identity with no stream (`stream: null`, and the state of every domain added before this existed) carries both.\n\nAt most one identity per (project, stream) is the default; setting `streamDefault` demotes whichever held it. `defaultFromAddress` has to be an address on this identity's own host — a default pointing anywhere else would go out unsigned by the name in the From header. Every field is optional and an omitted one is left alone.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
+ "operationId": "assignDomainStream",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AssignDomainStream"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Domain"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Updated sending identity"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:write"
+ ]
+ }
+ ],
+ "summary": "Assign a sending identity to a stream",
+ "tags": [
+ "Domains"
+ ]
+ }
+ },
+ "/api/domains/{id}/dodomain-session": {
+ "post": {
+ "description": "Mint a short-lived guided-setup session for this domain and return the URL that opens it. The URL is the whole point: DNS records have to be published at the domain's registrar, which is a place only a person with those credentials can reach — so this call cannot finish the job, it hands it over.\n\nThe session is bound to this one domain, expires on its own, and returns the browser to the Sendly domains settings page when it is done. Verification authority stays with SES either way: guided setup publishes the records, it does not decide whether they are correct.\n\n`503 DODOMAIN_NOT_CONFIGURED` when the deployment has no guided-setup provider. `429 DODOMAIN_SESSION_COOLDOWN` for a second call within 60s on the same domain — each session is metered, so a double submit is refused rather than charged twice.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
+ "operationId": "startDomainSetup",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "properties": {
+ "connectUrl": {
+ "description": "Open this in a browser to publish the records. Short-lived and domain-specific.",
+ "format": "uri",
+ "type": "string"
+ },
+ "expiresAt": {
+ "description": "When `connectUrl` stops working.",
+ "type": "string"
+ },
+ "token": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "token",
+ "connectUrl",
+ "expiresAt"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Guided setup session"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:write"
+ ]
+ }
+ ],
+ "summary": "Start guided DNS setup",
+ "tags": [
+ "Domains"
+ ]
+ }
+ },
+ "/api/domains/{id}/verify": {
+ "get": {
+ "description": "Read the current SES verification status without forcing a refresh.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
+ "operationId": "getDomainVerification",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/DomainVerificationStatus"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Verification status"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:write"
+ ]
+ }
+ ],
+ "summary": "Read SES verification status",
+ "tags": [
+ "Domains"
+ ]
+ },
+ "post": {
+ "description": "Force a refresh of the domain's SES verification status.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
+ "operationId": "verifyDomain",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/DomainVerificationStatus"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Verification status"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:write"
+ ]
+ }
+ ],
+ "summary": "Trigger SES verification",
+ "tags": [
+ "Domains"
+ ]
+ }
+ },
+ "/api/emails": {
+ "get": {
+ "description": "List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets.\n\nRequires the `emails:read` scope — View the emails you have sent and their delivery status.",
+ "operationId": "listEmails",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 50,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "cursor",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "tag",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Delivery lifecycle of the message. Engagement is reported separately.",
+ "in": "query",
+ "name": "status",
+ "required": false,
+ "schema": {
+ "$ref": "#/components/schemas/EmailDeliveryStatus"
+ }
+ },
+ {
+ "in": "query",
+ "name": "from",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmailListResponse"
+ }
+ }
+ },
+ "description": "Email list"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "emails:read"
+ ]
+ }
+ ],
+ "summary": "List emails",
+ "tags": [
+ "Emails"
+ ]
+ },
+ "post": {
+ "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.\n\nRequires the `emails:send` scope — Send emails from your verified domains.",
+ "operationId": "sendEmail",
+ "parameters": [
+ {
+ "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
+ "in": "header",
+ "name": "Idempotency-Key",
+ "required": false,
+ "schema": {
+ "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
+ "maxLength": 255,
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SendEmail"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SendEmailResponse"
+ }
+ }
+ },
+ "description": "Email accepted / sent"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows."
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent."
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly."
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "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."
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "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."
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "emails:send"
+ ]
+ }
+ ],
+ "summary": "Send a single transactional email",
+ "tags": [
+ "Emails"
+ ]
+ }
+ },
+ "/api/emails/batch": {
+ "post": {
+ "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.\n\nRequires the `emails:send` scope — Send emails from your verified domains.",
+ "operationId": "sendEmailBatch",
+ "parameters": [
+ {
+ "in": "header",
+ "name": "Idempotency-Key",
+ "required": false,
+ "schema": {
+ "maxLength": 255,
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BatchSendBody"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BatchSendResponse"
+ }
+ }
+ },
+ "description": "All entries sent"
+ },
+ "207": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BatchSendResponse"
+ }
+ }
+ },
+ "description": "Partial success — at least one entry failed"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows."
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent."
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly."
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "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."
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "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."
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "emails:send"
+ ]
+ }
+ ],
+ "summary": "Send a batch of emails",
+ "tags": [
+ "Emails"
+ ]
+ }
+ },
+ "/api/emails/{id}": {
+ "get": {
+ "description": "Fetch one email together with its DELIVERY history — the transitions behind `status`, oldest first.\n\n`events` here is not the custom-event resource: the events a caller records with `POST /api/v1/events` are read from `GET /api/v1/events`, and never appear on this response.\n\nRequires the `emails:read` scope — View the emails you have sent and their delivery status.",
+ "operationId": "getEmail",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmailDetailResponse"
+ }
+ }
+ },
+ "description": "Email and its delivery history"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "emails:read"
+ ]
+ }
+ ],
+ "summary": "Get a single email",
+ "tags": [
+ "Emails"
+ ]
+ }
+ },
+ "/api/emails/{id}/schedule": {
+ "delete": {
+ "description": "Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING.",
+ "operationId": "cancelScheduledEmail",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmailResponse"
+ }
+ }
+ },
+ "description": "Email cancelled"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Email already past PENDING"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ }
+ ],
+ "summary": "Cancel a scheduled (still-PENDING) email",
+ "tags": [
+ "Emails"
+ ]
+ }
+ },
+ "/api/lists/{id}/subscribe": {
+ "post": {
+ "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-subscription?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.",
+ "operationId": "subscribeToList",
+ "parameters": [
+ {
+ "description": "List id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "List id.",
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ListSubscribe"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ListSubscribeResponse"
+ }
+ }
+ },
+ "description": "Contact subscribed, or an existing membership returned unchanged"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "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."
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ }
+ ],
+ "summary": "Subscribe a contact to a list",
+ "tags": [
+ "Lists"
+ ]
+ }
+ },
+ "/api/lists/{id}/unsubscribe": {
+ "post": {
+ "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.",
+ "operationId": "unsubscribeFromList",
+ "parameters": [
+ {
+ "description": "List id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "List id.",
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ListUnsubscribe"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ListUnsubscribeResponse"
+ }
+ }
+ },
+ "description": "Contact unsubscribed"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ }
+ ],
+ "summary": "Unsubscribe a contact from a list",
+ "tags": [
+ "Lists"
+ ]
+ }
+ },
+ "/api/mailboxes": {
+ "get": {
+ "description": "Every mailbox on the authenticated project's domains, newest first. Not paginated: a project is capped at ten mailboxes, and the cap counts only those holding — or on their way to holding — a real account (`PROVISIONING`, `ACTIVE`, `SUSPENDED`). `FAILED` rows do not consume the cap but ARE returned here, so a project that has had failed provisions can list more than ten.\n\nThis lists the mailboxes themselves, never their contents: the messages a mailbox has received are not part of the public API and are not covered by this scope.\n\nRequires the `mailboxes:read` scope — View the mailboxes on your domains and their settings.",
+ "operationId": "listMailboxes",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/Mailbox"
+ },
+ "type": "array"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Mailbox list"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "mailboxes:read"
+ ]
+ }
+ ],
+ "summary": "List mailboxes",
+ "tags": [
+ "Mailboxes"
+ ]
+ },
+ "post": {
+ "description": "Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified.\n\nThree consequences worth knowing before you call it:\n\n- **It changes how your domain's mail is routed.** The first mailbox on a domain turns receiving on for that domain, so mail addressed there starts arriving at Sendly instead of wherever it went before.\n- **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS.\n- **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one.\n\nRetrying a failed provision with the same address reclaims the failed row rather than answering 409 — but only for the same project and the same domain.\n\n`quotaBytes` is accepted by the schema and REFUSED with a 400. Quotas are not implemented, and the field is still parsed so that asking for one is an error rather than a silently dropped key.\n\nRequires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains.",
+ "operationId": "createMailbox",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateMailboxBody"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Mailbox"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Mailbox provisioned"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "The address already exists, the domain is not verified, or the project is at its 10-mailbox limit."
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "502": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried."
+ }
+ },
+ "security": [
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "mailboxes:write"
+ ]
+ }
+ ],
+ "summary": "Create a mailbox",
+ "tags": [
+ "Mailboxes"
+ ]
+ }
+ },
+ "/api/mailboxes/{id}": {
+ "delete": {
+ "description": "Delete a mailbox and the mail account behind it. **Every message it holds is erased**, and Sendly keeps no other copy — this is not recoverable from the dashboard or by support. Mail sent to the address afterwards is rejected.\n\nRequires an admin of the project.\n\nRequires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains.",
+ "operationId": "deleteMailbox",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "properties": {
+ "deleted": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "deleted"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Mailbox deleted"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "mailboxes:write"
+ ]
+ }
+ ],
+ "summary": "Delete a mailbox",
+ "tags": [
+ "Mailboxes"
+ ]
+ },
+ "get": {
+ "description": "One mailbox, with the IMAP and SMTP host/port/username a mail client needs. The password is not included: mailbox credentials are app passwords, created separately and shown once.\n\nRequires the `mailboxes:read` scope — View the mailboxes on your domains and their settings.",
+ "operationId": "getMailbox",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/MailboxDetail"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Mailbox with connection settings"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "mailboxes:read"
+ ]
+ }
+ ],
+ "summary": "Get a mailbox",
+ "tags": [
+ "Mailboxes"
+ ]
+ }
+ },
+ "/api/mailboxes/{id}/app-passwords": {
+ "get": {
+ "description": "Every app password on the mailbox — name, protocols, last four characters and last use. The secrets themselves are stored hashed and are not retrievable here or anywhere else; a password you have lost is replaced, not recovered.\n\nRequires the `mailboxes:read` scope — View the mailboxes on your domains and their settings.",
+ "operationId": "listAppPasswords",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/AppPassword"
+ },
+ "type": "array"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "App password list"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "mailboxes:read"
+ ]
+ }
+ ],
+ "summary": "List a mailbox's app passwords",
+ "tags": [
+ "Mailboxes"
+ ]
+ },
+ "post": {
+ "description": "Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it.\n\n**The secret is not in the response.** A delegated caller receives `revealUrl` — a single-use link that shows the password once in a browser, to a signed-in project admin. The connection that created the password cannot open its own link, and the link is spent by the first attempt to open it, successful or not.\n\nThat is deliberate and not a limitation to work around: an app password is a live mail credential that a client authenticates with directly, it outlives the grant that created it, and it is revoked from a different screen. Returning it inline would place a working mail credential in an agent's context, its transcript, and every log that transcript reaches.\n\nRequires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection.\n\nRequires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains.",
+ "operationId": "createAppPassword",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateAppPassword"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/AppPasswordReveal"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "App password created; the secret is behind the one-time link"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "mailboxes:write"
+ ]
+ }
+ ],
+ "summary": "Create an app password",
+ "tags": [
+ "Mailboxes"
+ ]
+ }
+ },
+ "/api/mailboxes/{id}/app-passwords/{passwordId}": {
+ "delete": {
+ "description": "Revoke one app password. Any mail client still configured with it stops authenticating immediately — there is no grace period — and the mailbox and its messages are untouched.\n\nRequires an admin of the project. An API key is refused with 401.\n\nRequires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains.",
+ "operationId": "revokeAppPassword",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "passwordId",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "properties": {
+ "revoked": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "revoked"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "App password revoked"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "mailboxes:write"
+ ]
+ }
+ ],
+ "summary": "Revoke an app password",
+ "tags": [
+ "Mailboxes"
+ ]
+ }
+ },
+ "/api/mailboxes/{id}/drafts": {
+ "post": {
+ "description": "Ask Sendly's assistant to write a message for a mailbox — a new email from a short brief, a rewrite of something you already have, or a set of subject lines.\n\n**It returns text and sends nothing.** The response always reports `sent: false`, and there is no argument that changes that. Putting a draft in someone's inbox is a separate operation (`sendMailboxMessage`) under a separate scope, so a client that may draft is not thereby a client that may mail your customers.\n\nThat is also why this operation asks only for `mailboxes:read`: it names a mailbox so the draft can be written in that address's voice, reads no correspondence, and stores nothing.\n\nEverything you pass — the brief, the draft, the recipient context — is treated strictly as data describing what to write, never as instructions to the model.\n\nDrafting is capped at 120 requests per hour per project.\n\nRequires the `mailboxes:read` scope — View the mailboxes on your domains and their settings.",
+ "operationId": "draftMailboxMessage",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DraftMailboxMessage"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "properties": {
+ "body": {
+ "description": "Suggested plain-text body, or null.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sent": {
+ "description": "Always false. Reported rather than assumed, so a draft cannot be mistaken for a send.",
+ "enum": [
+ false
+ ],
+ "type": "boolean"
+ },
+ "subject": {
+ "description": "Suggested subject, or null.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "subjects": {
+ "description": "Alternative subject lines (`subject` mode); empty otherwise.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "subject",
+ "body",
+ "subjects",
+ "sent"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "A draft. Nothing was sent."
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "502": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "The drafting model was unreachable or returned nothing usable."
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "mailboxes:read"
+ ]
+ }
+ ],
+ "summary": "Draft a message with AI",
+ "tags": [
+ "Mailboxes"
+ ]
+ }
+ },
+ "/api/mailboxes/{id}/messages": {
+ "post": {
+ "description": "Compose and send a NEW message from a hosted mailbox — a first email to someone, not a reply inside a thread that already exists. It goes out from the mailbox's own address, over its own domain, and the recipient can reply to it.\n\n**The sender is the mailbox in the path.** There is no `from` field: a route that sends under a customer's own identity must not take the identity as an argument.\n\n**The body is plain text.** Sendly renders the HTML part from it, escaping as it goes, so there is one place where text becomes markup and it is inside Sendly. HTML is not accepted.\n\nBcc recipients are delivered to but appear in no header, which also means the copy filed in the mailbox's Sent folder does not record them.\n\nRefusals worth handling by name:\n\n- `422 RECIPIENT_SUPPRESSED` — one or more recipients are on this project's suppression list. The message names them; remove them or take them off the list.\n- `422 CONTENT_REFUSED` — the outbound content scanner refused the message.\n- `503 CONTENT_SCAN_UNAVAILABLE` — screening could not reach a verdict for a young project. Nothing was sent; retry shortly.\n- `429` — a mailbox may send 60 messages an hour through this endpoint.\n\nThe message is stored as a new conversation on the mailbox, so the reply threads onto it.\n\nRequires the `mailboxes:send` scope — Write and send new email from your hosted mailboxes, as that address.",
+ "operationId": "sendMailboxMessage",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ComposeMailboxMessage"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "properties": {
+ "conversationId": {
+ "description": "The conversation this send started. Replies thread onto it.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "messageId": {
+ "description": "The stored outbound message.",
+ "format": "uuid",
+ "type": "string"
+ },
+ "submitted": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "submitted",
+ "conversationId",
+ "messageId"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Message submitted"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`)."
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "502": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "The mail server refused the submission. Nothing was sent."
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Message screening could not reach a verdict. Nothing was sent; retry shortly."
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "mailboxes:send"
+ ]
+ }
+ ],
+ "summary": "Send a message from a mailbox",
+ "tags": [
+ "Mailboxes"
+ ]
+ }
+ },
+ "/api/projects/{id}/api-keys": {
+ "get": {
+ "description": "Returns every key on the project, revoked ones included — filter on `revokedAt` to show only live keys. Never returns a token or its hash: `lastFour` is the only fragment of the secret that survives creation.\n\nRequires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do.",
+ "operationId": "listApiKeys",
+ "parameters": [
+ {
+ "description": "Project id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Project id.",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiKeyListResponse"
+ }
+ }
+ },
+ "description": "API key list"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "api-keys:read"
+ ]
+ }
+ ],
+ "summary": "List API keys for a project",
+ "tags": [
+ "API Keys"
+ ]
+ },
+ "post": {
+ "description": "Mint a new API key on the project. The token is NOT returned — the response carries the key's metadata plus a one-time `revealUrl` that only a signed-in dashboard session can open.\n\n**Scope attenuation:** a key created with a delegated credential (an OAuth token or another API key) may not carry a scope that credential does not itself hold. A request that asks for more is refused with `400 SCOPE_ESCALATION` rather than quietly narrowed, so the mistake is reported where it was made instead of surfacing later as an unexplained 403. Naming only `legacyGrantPreset` counts as asking for every scope that preset implies.\n\nRequires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app.",
+ "operationId": "createApiKey",
+ "parameters": [
+ {
+ "description": "Project id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Project id.",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateApiKeyBody"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ApiKey"
+ },
+ {
+ "properties": {
+ "revealExpiresAt": {
+ "description": "When the reveal link stops working. Create or rotate again to get a new one.",
+ "format": "date-time",
+ "type": "string"
+ },
+ "revealUrl": {
+ "description": "A one-time, session-authenticated URL where the person who owns this project can see the secret. The secret itself is never returned to an API or agent caller: opening this link requires a signed-in dashboard session, so the credential that created the key cannot redeem it. Single use — the first successful open consumes it.",
+ "format": "uri",
+ "type": "string"
+ }
+ },
+ "required": [
+ "revealUrl",
+ "revealExpiresAt"
+ ],
+ "type": "object"
+ }
+ ],
+ "description": "An API key's metadata. Never carries the token or its hash — `lastFour` is the only surviving fragment of the secret once the key has been created."
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "API key created; the secret is behind the reveal link."
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "api-keys:write"
+ ]
+ }
+ ],
+ "summary": "Create an API key",
+ "tags": [
+ "API Keys"
+ ]
+ }
+ },
+ "/api/projects/{id}/api-keys/{keyId}": {
+ "delete": {
+ "description": "Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project.\n\nRequires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app.",
+ "operationId": "revokeApiKey",
+ "parameters": [
+ {
+ "description": "Project id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Project id.",
+ "type": "string"
+ }
+ },
+ {
+ "description": "API key id.",
+ "in": "path",
+ "name": "keyId",
+ "required": true,
+ "schema": {
+ "description": "API key id.",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SuccessEmpty"
+ }
+ }
+ },
+ "description": "API key revoked"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "api-keys:write"
+ ]
+ }
+ ],
+ "summary": "Revoke an API key",
+ "tags": [
+ "API Keys"
+ ]
+ }
+ },
+ "/api/projects/{id}/api-keys/{keyId}/rotate": {
+ "post": {
+ "description": "Replace the key's secret in place, keeping its id, name and scopes. The PREVIOUS secret stops authenticating immediately — there is no overlap window — so anything still using it starts failing on its next request. As with creation, the new secret is not returned: the response carries `lastFour` and a one-time `revealUrl`. A revoked key cannot be rotated (`400 KEY_REVOKED`).\n\nRequires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app.",
+ "operationId": "rotateApiKey",
+ "parameters": [
+ {
+ "description": "Project id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Project id.",
+ "type": "string"
+ }
+ },
+ {
+ "description": "API key id.",
+ "in": "path",
+ "name": "keyId",
+ "required": true,
+ "schema": {
+ "description": "API key id.",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "properties": {
+ "lastFour": {
+ "type": "string"
+ },
+ "revealExpiresAt": {
+ "description": "When the reveal link stops working. Create or rotate again to get a new one.",
+ "format": "date-time",
+ "type": "string"
+ },
+ "revealUrl": {
+ "description": "A one-time, session-authenticated URL where the person who owns this project can see the secret. The secret itself is never returned to an API or agent caller: opening this link requires a signed-in dashboard session, so the credential that created the key cannot redeem it. Single use — the first successful open consumes it.",
+ "format": "uri",
+ "type": "string"
+ }
+ },
+ "required": [
+ "lastFour",
+ "revealUrl",
+ "revealExpiresAt"
+ ],
+ "type": "object"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "API key rotated; the new secret is behind the reveal link."
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "api-keys:write"
+ ]
+ }
+ ],
+ "summary": "Rotate an API key's secret",
+ "tags": [
+ "API Keys"
+ ]
+ }
+ },
+ "/api/snippets": {
+ "get": {
+ "description": "Cursor-paginated list of the project's reusable template fragments. `search` matches name and description.\n\nRequires the `templates:read` scope — View your email templates.",
+ "operationId": "listSnippets",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "cursor",
+ "required": false,
+ "schema": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "search",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SnippetListResponse"
+ }
+ }
+ },
+ "description": "Snippet list"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "templates:read"
+ ]
+ }
+ ],
+ "summary": "List snippets",
+ "tags": [
+ "Templates"
+ ]
+ },
+ "post": {
+ "description": "`name` is the literal identifier templates include with `{{> name}}`: it must start with a letter and contain only letters, digits, hyphen or underscore, and it is unique within the project.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
+ "operationId": "createSnippet",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateSnippet"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Snippet"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Snippet created"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "A snippet with that name already exists in this project"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "templates:write"
+ ]
+ }
+ ],
+ "summary": "Create a snippet",
+ "tags": [
+ "Templates"
+ ]
+ }
+ },
+ "/api/snippets/{id}": {
+ "delete": {
+ "description": "Templates that still include the snippet keep rendering — an absent snippet renders as an empty string, exactly like an absent variable.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
+ "operationId": "deleteSnippet",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IdResponse"
+ }
+ }
+ },
+ "description": "Snippet deleted"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "templates:write"
+ ]
+ }
+ ],
+ "summary": "Delete a snippet",
+ "tags": [
+ "Templates"
+ ]
+ },
+ "get": {
+ "description": "Requires the `templates:read` scope — View your email templates.",
+ "operationId": "getSnippet",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Snippet"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Snippet"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "templates:read"
+ ]
+ }
+ ],
+ "summary": "Get a snippet",
+ "tags": [
+ "Templates"
+ ]
+ },
+ "patch": {
+ "description": "Requires the `templates:write` scope — Create, edit, and delete your email templates.",
+ "operationId": "updateSnippet",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSnippet"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Snippet"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Updated snippet"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "A snippet with that name already exists in this project"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "templates:write"
+ ]
+ }
+ ],
+ "summary": "Update a snippet",
+ "tags": [
+ "Templates"
+ ]
+ }
+ },
+ "/api/suppression": {
+ "get": {
+ "description": "Cursor-paginated list of suppressed addresses. Filter by `reason`.\n\nRequires the `suppression:read` scope — View the addresses on your suppression list.",
+ "operationId": "listSuppressions",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 50,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "cursor",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "reason",
+ "required": false,
+ "schema": {
+ "enum": [
+ "HARD_BOUNCE",
+ "COMPLAINT",
+ "MANUAL",
+ "UNSUBSCRIBE"
+ ],
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SuppressionListResponse"
+ }
+ }
+ },
+ "description": "Suppression list"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "suppression:read"
+ ]
+ }
+ ],
+ "summary": "List suppressed emails",
+ "tags": [
+ "Suppression"
+ ]
+ },
+ "post": {
+ "description": "The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers.\n\nRequires the `suppression:write` scope — Add and remove addresses on your suppression list.",
+ "operationId": "addSuppression",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AddSuppression"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Suppression"
+ }
+ }
+ },
+ "description": "Suppression added"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "suppression:write"
+ ]
+ }
+ ],
+ "summary": "Manually add an email to the suppression list",
+ "tags": [
+ "Suppression"
+ ]
+ }
+ },
+ "/api/suppression/{email}": {
+ "delete": {
+ "description": "Idempotent. Silently no-ops if the suppression doesn't exist.\n\nRequires the `suppression:write` scope — Add and remove addresses on your suppression list.",
+ "operationId": "removeSuppression",
+ "parameters": [
+ {
+ "description": "URL-encoded email address",
+ "in": "path",
+ "name": "email",
+ "required": true,
+ "schema": {
+ "description": "URL-encoded email address",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Suppression removed"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "suppression:write"
+ ]
+ }
+ ],
+ "summary": "Remove an email from the suppression list",
+ "tags": [
+ "Suppression"
+ ]
+ },
+ "get": {
+ "description": "Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded.\n\nRequires the `suppression:read` scope — View the addresses on your suppression list.",
+ "operationId": "checkSuppression",
+ "parameters": [
+ {
+ "description": "URL-encoded email address",
+ "in": "path",
+ "name": "email",
+ "required": true,
+ "schema": {
+ "description": "URL-encoded email address",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SuppressionCheckResponse"
+ }
+ }
+ },
+ "description": "Suppression check result"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "suppression:read"
+ ]
+ }
+ ],
+ "summary": "Check whether an email is suppressed",
+ "tags": [
+ "Suppression"
+ ]
+ }
+ },
+ "/api/templates": {
+ "get": {
+ "description": "Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject.\n\nRequires the `templates:read` scope — View your email templates.",
+ "operationId": "listTemplates",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "cursor",
+ "required": false,
+ "schema": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "search",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "emailCategory",
+ "required": false,
+ "schema": {
+ "enum": [
+ "MARKETING",
+ "TRANSACTIONAL",
+ "SELF_MANAGED_UNSUBSCRIBE"
+ ],
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TemplateListResponse"
+ }
+ }
+ },
+ "description": "Template list"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "templates:read"
+ ]
+ }
+ ],
+ "summary": "List templates",
+ "tags": [
+ "Templates"
+ ]
+ },
+ "post": {
+ "description": "Create a new email template. The `from` domain must already be verified for the project.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
+ "operationId": "createTemplate",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateTemplate"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Template"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Template created"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "templates:write"
+ ]
+ }
+ ],
+ "summary": "Create a template",
+ "tags": [
+ "Templates"
+ ]
+ }
+ },
+ "/api/templates/{id}": {
+ "delete": {
+ "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.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
+ "operationId": "deleteTemplate",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IdResponse"
+ }
+ }
+ },
+ "description": "Template deleted"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Template still in use"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "templates:write"
+ ]
+ }
+ ],
+ "summary": "Delete a template",
+ "tags": [
+ "Templates"
+ ]
+ },
+ "get": {
+ "description": "Requires the `templates:read` scope — View your email templates.",
+ "operationId": "getTemplate",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Template"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Template"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "templates:read"
+ ]
+ }
+ ],
+ "summary": "Get a template",
+ "tags": [
+ "Templates"
+ ]
+ },
+ "patch": {
+ "description": "Update one or more fields. If `from` changes, the new domain must already be verified.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
+ "operationId": "updateTemplate",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateTemplate"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/Template"
+ },
+ "success": {
+ "enum": [
+ true
+ ],
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Updated template"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "templates:write"
+ ]
+ }
+ ],
+ "summary": "Update a template",
+ "tags": [
+ "Templates"
+ ]
+ }
+ },
+ "/api/track": {
+ "post": {
+ "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.\n\nRequires the `events:write` scope — Record custom events for your contacts.",
+ "operationId": "trackEvent",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TrackEvent"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TrackEventResponse"
+ }
+ }
+ },
+ "description": "Event tracked"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "OAuth2": [
+ "events:write"
+ ]
+ }
+ ],
+ "summary": "Track a custom event for a contact",
+ "tags": [
+ "Events"
+ ]
+ }
+ },
+ "/api/users/me/projects": {
+ "post": {
+ "description": "Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201.\n\nPreconditions the route enforces before writing: the caller's email must be verified, the caller must be under their cap on active (non-disabled) owned projects, and the call is rate-limited per user.\n\nRequires the `projects:write` scope — Create new projects on your account.",
+ "operationId": "createProject",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "type": "string"
+ },
+ "sesRegion": {
+ "description": "AWS SES region for the project. Once a domain is added the region is locked and cannot be changed.",
+ "enum": [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProjectRecord"
+ }
+ }
+ },
+ "description": "Project created"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation error"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Unauthorized — missing or invalid auth"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Forbidden — insufficient permissions or project disabled"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Validation failed — request body or query parameters did not match the schema"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Rate limit or billing limit exceeded"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ },
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "projects:write"
+ ]
+ }
+ ],
+ "summary": "Create a project",
+ "tags": [
+ "Projects"
+ ]
+ }
+ },
+ "/api/v1/analytics/campaigns": {
+ "get": {
+ "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.",
+ "operationId": "v1GetCampaignAnalytics",
+ "parameters": [
+ {
+ "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
+ "in": "query",
+ "name": "from",
+ "required": false,
+ "schema": {
+ "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "description": "End of the window (ISO 8601). Defaults to now.",
+ "in": "query",
+ "name": "to",
+ "required": false,
+ "schema": {
+ "description": "End of the window (ISO 8601). Defaults to now.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AnalyticsCampaignStatsV1"
+ }
+ }
+ },
+ "description": "Campaign statistics"
+ },
+ "401": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
+ },
+ "429": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
+ },
+ "500": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`internal_error`."
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "analytics:read"
+ ]
+ }
+ ],
+ "summary": "Retrieve campaign totals and engagement",
+ "tags": [
+ "Analytics"
+ ]
+ }
+ },
+ "/api/v1/analytics/timeseries": {
+ "get": {
+ "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.",
+ "operationId": "v1GetAnalyticsTimeseries",
+ "parameters": [
+ {
+ "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
+ "in": "query",
+ "name": "from",
+ "required": false,
+ "schema": {
+ "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "description": "End of the window (ISO 8601). Defaults to now.",
+ "in": "query",
+ "name": "to",
+ "required": false,
+ "schema": {
+ "description": "End of the window (ISO 8601). Defaults to now.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AnalyticsTimeseriesV1"
+ }
+ }
+ },
+ "description": "Daily time series"
+ },
+ "401": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
+ },
+ "429": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
+ },
+ "500": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`internal_error`."
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "analytics:read"
+ ]
+ }
+ ],
+ "summary": "Retrieve the daily email time series",
+ "tags": [
+ "Analytics"
+ ]
+ }
+ },
+ "/api/v1/analytics/top-campaigns": {
+ "get": {
+ "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.",
+ "operationId": "v1ListTopCampaigns",
+ "parameters": [
+ {
+ "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
+ "in": "query",
+ "name": "from",
+ "required": false,
+ "schema": {
+ "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "description": "End of the window (ISO 8601). Defaults to now.",
+ "in": "query",
+ "name": "to",
+ "required": false,
+ "schema": {
+ "description": "End of the window (ISO 8601). Defaults to now.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 10,
+ "maximum": 50,
+ "minimum": 1,
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AnalyticsTopCampaignsV1"
+ }
+ }
+ },
+ "description": "Ranked campaigns"
+ },
+ "401": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
+ },
+ "429": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
+ },
+ "500": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`internal_error`."
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "analytics:read"
+ ]
+ }
+ ],
+ "summary": "List the best-performing campaigns",
+ "tags": [
+ "Analytics"
+ ]
+ }
+ },
+ "/api/v1/campaigns": {
+ "get": {
+ "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\nUnlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.",
+ "operationId": "v1ListCampaigns",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CampaignV1List"
+ }
+ }
+ },
+ "description": "Campaign list"
+ },
+ "401": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
+ },
+ "429": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
+ },
+ "500": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`internal_error`."
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "campaigns:read"
+ ]
+ }
+ ],
+ "summary": "List campaigns",
+ "tags": [
+ "Campaigns"
+ ]
+ },
+ "post": {
+ "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, and organize your campaigns.",
+ "operationId": "v1CreateCampaign",
+ "parameters": [
+ {
+ "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
+ "in": "header",
+ "name": "Idempotency-Key",
+ "required": false,
+ "schema": {
+ "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
+ "maxLength": 255,
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CampaignV1Create"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CampaignV1"
+ }
+ }
+ },
+ "description": "Campaign created"
+ },
+ "401": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
+ },
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`resource_not_found` — `segment_id` names a segment that does not belong to this project."
+ },
+ "409": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "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."
+ },
+ "429": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
+ },
+ "500": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`internal_error`."
+ }
+ },
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "campaigns:write"
+ ]
+ }
+ ],
+ "summary": "Create a campaign",
+ "tags": [
+ "Campaigns"
+ ]
+ }
+ },
+ "/api/v1/campaigns/{id}": {
+ "delete": {
+ "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, and organize your campaigns.",
+ "operationId": "v1DeleteCampaign",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CampaignV1Deleted"
+ }
+ }
+ },
+ "description": "Campaign deleted"
+ },
+ "400": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — only `DRAFT` campaigns can be deleted."
+ },
+ "401": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
+ },
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
+ },
+ "429": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
+ },
+ "500": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`internal_error`."
+ }
+ },
+ "security": [
{
- "in": "query",
- "name": "cursor",
- "required": false,
- "schema": {
- "type": "string"
- }
+ "ApiKeyAuth": []
},
{
- "in": "query",
- "name": "search",
- "required": false,
- "schema": {
- "type": "string"
- }
+ "SessionAuth": []
},
{
- "in": "query",
- "name": "subscribed",
- "required": false,
+ "OAuth2": [
+ "campaigns:write"
+ ]
+ }
+ ],
+ "summary": "Delete a campaign",
+ "tags": [
+ "Campaigns"
+ ]
+ },
+ "get": {
+ "description": "Fetch one campaign, including its materialized delivery counters.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.",
+ "operationId": "v1GetCampaign",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
"schema": {
- "enum": [
- "true",
- "false"
- ],
+ "description": "Resource id.",
+ "format": "uuid",
"type": "string"
}
}
@@ -4472,71 +15394,71 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ContactListResponse"
+ "$ref": "#/components/schemas/CampaignV1"
}
}
},
- "description": "Contact list"
+ "description": "The campaign"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -4548,123 +15470,121 @@
},
{
"OAuth2": [
- "contacts:read"
+ "campaigns:read"
]
}
],
- "summary": "List contacts",
+ "summary": "Retrieve a campaign",
"tags": [
- "Contacts"
+ "Campaigns"
]
},
- "post": {
- "description": "Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
- "operationId": "createContact",
+ "patch": {
+ "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, and organize your campaigns.",
+ "operationId": "v1UpdateCampaign",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CreateContact"
+ "$ref": "#/components/schemas/CampaignV1Update"
}
}
},
"required": true
},
"responses": {
- "201": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Contact"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/CampaignV1"
}
}
},
- "description": "Contact created"
+ "description": "The updated campaign"
},
"400": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`validation_error` — the campaign is not in an editable status, or the segment change is not allowed."
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "409": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Email already exists for this project"
+ "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -4676,123 +15596,113 @@
},
{
"OAuth2": [
- "contacts:write"
+ "campaigns:write"
]
}
],
- "summary": "Create a contact",
+ "summary": "Update a campaign",
"tags": [
- "Contacts"
+ "Campaigns"
]
}
},
- "/api/contacts/bulk": {
- "delete": {
- "description": "Delete up to 1000 contacts in one call. Provide either `ids` or `emails`.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
- "operationId": "bulkDeleteContacts",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ContactBulkDeleteBody"
- }
+ "/api/v1/campaigns/{id}/cancel": {
+ "post": {
+ "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, and organize your campaigns.",
+ "operationId": "v1CancelCampaign",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
}
- },
- "required": true
- },
+ }
+ ],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "properties": {
- "deleted": {
- "type": "integer"
- }
- },
- "required": [
- "deleted"
- ],
- "type": "object"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/CampaignV1"
}
}
},
- "description": "Bulk-delete result"
+ "description": "The cancelled campaign"
+ },
+ "400": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled."
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -4804,144 +15714,125 @@
},
{
"OAuth2": [
- "contacts:write"
+ "campaigns:write"
]
}
],
- "summary": "Bulk-delete contacts",
+ "summary": "Cancel a campaign",
"tags": [
- "Contacts"
+ "Campaigns"
]
- },
- "post": {
- "description": "Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
- "operationId": "bulkCreateContacts",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ContactBulkCreateBody"
- }
+ }
+ },
+ "/api/v1/campaigns/{id}/failures": {
+ "get": {
+ "description": "The recipients this campaign did not reach, read from its per-contact send ledger. The campaign counters say how many were sent; only this says WHO was dropped and why, which is what makes retrying a decision rather than a guess.\n\n`reason` comes from a fixed vocabulary, not from the underlying error text, so it is stable enough to branch on. It is `null` for rows recorded before reasons were captured.\n\nCursor-paginated like every other v1 list. Unlike them it also returns `total`: the retry action operates on that number, and a page that can only say `has_more` cannot tell you whether 3 or 30,000 sends failed.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.",
+ "operationId": "v1ListCampaignFailures",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
}
},
- "required": true
- },
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "properties": {
- "created": {
- "type": "integer"
- },
- "errors": {
- "items": {
- "properties": {
- "index": {
- "type": "integer"
- },
- "message": {
- "type": "string"
- }
- },
- "required": [
- "index",
- "message"
- ],
- "type": "object"
- },
- "type": "array"
- },
- "skipped": {
- "type": "integer"
- }
- },
- "required": [
- "created",
- "skipped",
- "errors"
- ],
- "type": "object"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/CampaignV1FailureList"
}
}
},
- "description": "Bulk-create result"
+ "description": "Failed sends"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -4953,115 +15844,113 @@
},
{
"OAuth2": [
- "contacts:write"
+ "campaigns:read"
]
}
],
- "summary": "Bulk-create contacts",
+ "summary": "List a campaign's failed sends",
"tags": [
- "Contacts"
+ "Campaigns"
]
}
},
- "/api/contacts/upsert": {
+ "/api/v1/campaigns/{id}/pause": {
"post": {
- "description": "Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
- "operationId": "upsertContact",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/CreateContact"
- }
+ "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, and organize your campaigns.",
+ "operationId": "v1PauseCampaign",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
}
- },
- "required": true
- },
+ }
+ ],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Contact"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/CampaignV1"
}
}
},
- "description": "Contact created or updated"
+ "description": "The paused campaign"
},
"400": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`validation_error` — only a `SENDING` campaign can be paused."
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -5073,26 +15962,28 @@
},
{
"OAuth2": [
- "contacts:write"
+ "campaigns:write"
]
}
],
- "summary": "Create or update a contact by email",
+ "summary": "Pause a sending campaign",
"tags": [
- "Contacts"
+ "Campaigns"
]
}
},
- "/api/contacts/{id}": {
- "delete": {
- "description": "Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content).\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
- "operationId": "deleteContact",
+ "/api/v1/campaigns/{id}/resume": {
+ "post": {
+ "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, and organize your campaigns.",
+ "operationId": "v1ResumeCampaign",
"parameters": [
{
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
+ "description": "Resource id.",
"format": "uuid",
"type": "string"
}
@@ -5103,71 +15994,81 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/IdResponse"
+ "$ref": "#/components/schemas/CampaignV1"
}
}
},
- "description": "Contact deleted"
+ "description": "The resumed campaign"
},
"400": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`validation_error` — only a `PAUSED` campaign can be resumed."
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -5179,24 +16080,28 @@
},
{
"OAuth2": [
- "contacts:write"
+ "campaigns:write"
]
}
],
- "summary": "Delete a contact",
+ "summary": "Resume a paused campaign",
"tags": [
- "Contacts"
+ "Campaigns"
]
- },
- "get": {
- "description": "Requires the `contacts:read` scope — View your contacts and their custom fields.",
- "operationId": "getContact",
+ }
+ },
+ "/api/v1/campaigns/{id}/retry-failed": {
+ "post": {
+ "description": "Re-drive only the recipients whose send failed, through the same pipeline the campaign used. Nobody who was already mailed is mailed again: each ledger row is claimed before it is touched, and a row whose email was created before the failure was recorded is re-queued rather than re-sent.\n\nThe retry runs in the background, so this returns as soon as it is queued, with the number of rows it was queued for. Takes no request body.\n\nOnly a `SENT` campaign can be retried: a `SENDING` or `PAUSED` one still has its own send in progress against the same ledger, and a `CANCELLED` one was stopped deliberately.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.",
+ "operationId": "v1RetryCampaignFailures",
"parameters": [
{
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
+ "description": "Resource id.",
"format": "uuid",
"type": "string"
}
@@ -5207,86 +16112,91 @@
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Contact"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/CampaignV1RetryFailed"
}
}
},
- "description": "Contact"
+ "description": "The retry was queued"
},
"400": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`validation_error` — only a `SENT` campaign can have its failed sends retried."
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
+ },
+ "409": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`conflict` — a retry is already running for this campaign."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -5298,134 +16208,145 @@
},
{
"OAuth2": [
- "contacts:read"
+ "campaigns:write"
]
}
],
- "summary": "Get a contact",
+ "summary": "Retry a campaign's failed sends",
"tags": [
- "Contacts"
+ "Campaigns"
]
- },
- "patch": {
- "description": "Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
- "operationId": "updateContact",
+ }
+ },
+ "/api/v1/campaigns/{id}/send": {
+ "post": {
+ "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:send` scope — Send or schedule your campaigns to their audience.",
+ "operationId": "v1SendCampaign",
"parameters": [
{
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
+ "description": "Resource id.",
"format": "uuid",
"type": "string"
}
+ },
+ {
+ "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
+ "in": "header",
+ "name": "Idempotency-Key",
+ "required": false,
+ "schema": {
+ "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
+ "maxLength": 255,
+ "minLength": 1,
+ "type": "string"
+ }
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateContactBody"
+ "$ref": "#/components/schemas/CampaignV1Send"
}
}
},
- "required": true
+ "required": false
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Contact"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/CampaignV1"
}
}
},
- "description": "Updated contact"
+ "description": "The campaign, now `SENDING` or `SCHEDULED`"
},
"400": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future."
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
},
- "404": {
+ "409": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "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."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -5437,80 +16358,103 @@
},
{
"OAuth2": [
- "contacts:write"
+ "campaigns:send"
]
}
],
- "summary": "Update a contact",
+ "summary": "Send or schedule a campaign",
"tags": [
- "Contacts"
+ "Campaigns"
]
}
},
- "/api/domains": {
+ "/api/v1/campaigns/{id}/stats": {
"get": {
- "description": "List all domains for the authenticated project.\n\nRequires the `domains:read` scope — View your sending domains and their verification status.",
- "operationId": "listDomains",
+ "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.",
+ "operationId": "v1GetCampaignStats",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/DomainListResponse"
+ "$ref": "#/components/schemas/CampaignV1Stats"
}
}
},
- "description": "Domain list"
+ "description": "Campaign statistics"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -5522,113 +16466,129 @@
},
{
"OAuth2": [
- "domains:read"
+ "campaigns:read"
]
}
],
- "summary": "List sending domains",
+ "summary": "Retrieve campaign statistics",
"tags": [
- "Domains"
+ "Campaigns"
]
- },
- "post": {
- "description": "Register a new domain with SES and persist its DKIM tokens.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
- "operationId": "addDomain",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/AddDomainBody"
- }
+ }
+ },
+ "/api/v1/contacts": {
+ "get": {
+ "description": "Cursor-paginated list of contacts, 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\nA cursor is bound to the filters that minted it. Changing `search` or `subscribed` while reusing a cursor answers `422 validation_error` rather than returning a page that belongs to neither query — drop the cursor and start again from the first page.\n\nRequires the `contacts:read` scope — View your contacts and their custom fields.",
+ "operationId": "v1ListContacts",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
}
},
- "required": true
- },
- "responses": {
- "201": {
- "content": {
- "application/json": {
- "schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Domain"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
- }
- }
- },
- "description": "Domain added"
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
+ "type": "string"
+ }
},
- "400": {
+ {
+ "description": "Case-insensitive substring match on the email address.",
+ "in": "query",
+ "name": "search",
+ "required": false,
+ "schema": {
+ "description": "Case-insensitive substring match on the email address.",
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both.",
+ "in": "query",
+ "name": "subscribed",
+ "required": false,
+ "schema": {
+ "description": "Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both.",
+ "enum": [
+ "true",
+ "false"
+ ],
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/ContactV1List"
}
}
},
- "description": "Validation error"
+ "description": "Contact list"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "429": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
- "500": {
+ "429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
- "502": {
+ "500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "AWS SES rejected the identity setup (unusable credentials, a missing IAM permission, or a refusal on Amazon's side). No domain row is written, and the request is not at fault — retrying it unchanged will not help."
+ "description": "`internal_error`."
}
},
"security": [
@@ -5639,102 +16599,99 @@
"SessionAuth": []
},
{
- "OAuth2": [
- "domains:write"
- ]
- }
- ],
- "summary": "Add a sending domain",
- "tags": [
- "Domains"
- ]
- }
- },
- "/api/domains/{id}": {
- "delete": {
- "description": "Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
- "operationId": "deleteDomain",
- "parameters": [
- {
- "in": "path",
- "name": "id",
- "required": true,
- "schema": {
- "format": "uuid",
- "type": "string"
- }
+ "OAuth2": [
+ "contacts:read"
+ ]
}
],
+ "summary": "List contacts",
+ "tags": [
+ "Contacts"
+ ]
+ },
+ "post": {
+ "description": "Create a contact. The address is unique per project, so creating one that already exists answers `409 conflict` rather than updating the existing row — there is no upsert on this surface, because a silent update is not what a caller who wrote `create` asked for.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
+ "operationId": "v1CreateContact",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ContactV1Create"
+ }
+ }
+ },
+ "required": true
+ },
"responses": {
- "200": {
+ "201": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SuccessEmpty"
+ "$ref": "#/components/schemas/ContactV1"
}
}
},
- "description": "Domain removed"
+ "description": "The created contact"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "409": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`conflict` — a contact with this email already exists in this project."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -5746,24 +16703,28 @@
},
{
"OAuth2": [
- "domains:write"
+ "contacts:write"
]
}
],
- "summary": "Remove a sending domain",
+ "summary": "Create a contact",
"tags": [
- "Domains"
+ "Contacts"
]
- },
- "get": {
- "description": "Requires the `domains:read` scope — View your sending domains and their verification status.",
- "operationId": "getDomain",
+ }
+ },
+ "/api/v1/contacts/{id}": {
+ "delete": {
+ "description": "Delete the contact row. The emails already sent to that address are NOT erased — a send is a record of something that happened, and removing the recipient does not undo it. Erasing delivery history is a separate, irreversible operation that this surface deliberately does not expose.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
+ "operationId": "v1DeleteContact",
"parameters": [
{
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
+ "description": "Resource id.",
"format": "uuid",
"type": "string"
}
@@ -5774,86 +16735,71 @@
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Domain"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/ContactV1Deleted"
}
}
},
- "description": "Domain"
+ "description": "Contact deleted"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no contact with this id belongs to the authenticated project."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -5865,26 +16811,26 @@
},
{
"OAuth2": [
- "domains:read"
+ "contacts:write"
]
}
],
- "summary": "Get a sending domain",
+ "summary": "Delete a contact",
"tags": [
- "Domains"
+ "Contacts"
]
- }
- },
- "/api/domains/{id}/dodomain-session": {
- "post": {
- "description": "Mint a short-lived guided-setup session for this domain and return the URL that opens it. The URL is the whole point: DNS records have to be published at the domain's registrar, which is a place only a person with those credentials can reach — so this call cannot finish the job, it hands it over.\n\nThe session is bound to this one domain, expires on its own, and returns the browser to the Sendly domains settings page when it is done. Verification authority stays with SES either way: guided setup publishes the records, it does not decide whether they are correct.\n\n`503 DODOMAIN_NOT_CONFIGURED` when the deployment has no guided-setup provider. `429 DODOMAIN_SESSION_COOLDOWN` for a second call within 60s on the same domain — each session is metered, so a double submit is refused rather than charged twice.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
- "operationId": "startDomainSetup",
+ },
+ "get": {
+ "description": "Fetch one contact by id.\n\nRequires the `contacts:read` scope — View your contacts and their custom fields.",
+ "operationId": "v1GetContact",
"parameters": [
{
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
+ "description": "Resource id.",
"format": "uuid",
"type": "string"
}
@@ -5895,105 +16841,71 @@
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "properties": {
- "connectUrl": {
- "description": "Open this in a browser to publish the records. Short-lived and domain-specific.",
- "format": "uri",
- "type": "string"
- },
- "expiresAt": {
- "description": "When `connectUrl` stops working.",
- "type": "string"
- },
- "token": {
- "type": "string"
- }
- },
- "required": [
- "token",
- "connectUrl",
- "expiresAt"
- ],
- "type": "object"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/ContactV1"
}
}
},
- "description": "Guided setup session"
+ "description": "The contact"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no contact with this id belongs to the authenticated project."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -6005,116 +16917,111 @@
},
{
"OAuth2": [
- "domains:write"
+ "contacts:read"
]
}
],
- "summary": "Start guided DNS setup",
+ "summary": "Retrieve a contact",
"tags": [
- "Domains"
+ "Contacts"
]
- }
- },
- "/api/domains/{id}/verify": {
- "get": {
- "description": "Read the current SES verification status without forcing a refresh.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
- "operationId": "getDomainVerification",
+ },
+ "patch": {
+ "description": "Partial update. Omitted fields are left alone.\n\n`email` is NOT writable: an address is the contact's identity on this API, and rewriting it in place would silently change what every earlier send was addressed to. Create the new address instead.\n\n`custom_fields` REPLACES the stored object rather than merging into it, so a key you omit is gone. Read the contact first if you mean to change one key and keep the rest.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.",
+ "operationId": "v1UpdateContact",
"parameters": [
{
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
+ "description": "Resource id.",
"format": "uuid",
"type": "string"
}
}
],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ContactV1Update"
+ }
+ }
+ },
+ "required": true
+ },
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/DomainVerificationStatus"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/ContactV1"
}
}
},
- "description": "Verification status"
+ "description": "The updated contact"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no contact with this id belongs to the authenticated project."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -6126,24 +17033,28 @@
},
{
"OAuth2": [
- "domains:write"
+ "contacts:write"
]
}
],
- "summary": "Read SES verification status",
+ "summary": "Update a contact",
"tags": [
- "Domains"
+ "Contacts"
]
- },
- "post": {
- "description": "Force a refresh of the domain's SES verification status.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
- "operationId": "verifyDomain",
+ }
+ },
+ "/api/v1/contacts/{id}/topics": {
+ "get": {
+ "description": "Everything this contact has said they want, as the send path reads it.\n\n`subscribed` on each topic is the EFFECTIVE answer: a contact who has never answered has no row at all, and the topic's `default_opt_in` decides what that silence means. It is folded in here so no caller has to reimplement the rule.\n\nThe top-level `subscribed` is the global marketing opt-out, and it OUTRANKS every topic. A caller reading only the topic list would conclude somebody is reachable when they are not.\n\nRequires the `topics:read` scope — View the topics you mail about and who is subscribed to each.",
+ "operationId": "v1GetContactTopicPreferences",
"parameters": [
{
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
+ "description": "Resource id.",
"format": "uuid",
"type": "string"
}
@@ -6154,86 +17065,71 @@
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/DomainVerificationStatus"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/ContactTopicPreferencesV1"
}
}
},
- "description": "Verification status"
+ "description": "The contact's preferences"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no contact with this id belongs to the authenticated project."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -6245,72 +17141,54 @@
},
{
"OAuth2": [
- "domains:write"
+ "topics:read"
]
}
],
- "summary": "Trigger SES verification",
+ "summary": "Get a contact's topic preferences",
"tags": [
- "Domains"
+ "Topics"
]
}
},
- "/api/emails": {
+ "/api/v1/deliverability/diagnose": {
"get": {
- "description": "List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets.\n\nRequires the `emails:read` scope — View the emails you have sent and their delivery status.",
- "operationId": "listEmails",
+ "description": "Reads the sending domain's DNS identity, the project's delivery outcomes over the last `window_days`, and — when an `address` is given — that recipient's suppression state, then publishes `findings`: what is actually wrong, worst first, each with a stable `code` and the fix. Branch on `code`, never on the prose.\n\nEverything here is read from data the platform already holds. The DNS statuses are the cached results of the verification refresh job, NOT a live lookup — `identity.last_checked_at` says when they were filled, and a domain that has never been checked reports nulls with a `dns_never_checked` finding rather than failures.\n\n`recent_delivery` is PROJECT-WIDE, not per-domain, because an email row records no sending domain; the field says so in its own `scope`. Rates are suppressed as meaningless below 20 sends in the window.\n\nRequires the `deliverability:read` scope — Check why mail from one of your domains is not arriving.",
+ "operationId": "v1DiagnoseDeliverability",
"parameters": [
{
+ "description": "A sending domain in this project, e.g. `example.com`.",
"in": "query",
- "name": "limit",
- "required": false,
- "schema": {
- "default": 50,
- "maximum": 100,
- "minimum": 1,
- "type": "integer"
- }
- },
- {
- "in": "query",
- "name": "cursor",
- "required": false,
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "tag",
- "required": false,
+ "name": "domain",
+ "required": true,
"schema": {
+ "description": "A sending domain in this project, e.g. `example.com`.",
+ "maxLength": 253,
+ "minLength": 3,
"type": "string"
}
},
{
+ "description": "Optionally, one RECIPIENT address to check as well. Adds its suppression state — the single most common reason a specific person stops receiving mail while everyone else still does.",
"in": "query",
- "name": "status",
+ "name": "address",
"required": false,
"schema": {
- "enum": [
- "PENDING",
- "SENT",
- "DELIVERED",
- "OPENED",
- "CLICKED",
- "BOUNCED",
- "COMPLAINED",
- "FAILED"
- ],
+ "description": "Optionally, one RECIPIENT address to check as well. Adds its suppression state — the single most common reason a specific person stops receiving mail while everyone else still does.",
+ "format": "email",
"type": "string"
}
},
{
+ "description": "How far back the delivery counters look. 1–30 days; defaults to 7.",
"in": "query",
- "name": "from",
+ "name": "window_days",
"required": false,
"schema": {
- "type": "string"
+ "description": "How far back the delivery counters look. 1–30 days; defaults to 7.",
+ "maximum": 30,
+ "minimum": 1,
+ "type": "integer"
}
}
],
@@ -6319,61 +17197,61 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/EmailListResponse"
+ "$ref": "#/components/schemas/DeliverabilityDiagnosisV1"
}
}
},
- "description": "Email list"
+ "description": "The diagnosis, with findings"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -6385,132 +17263,128 @@
},
{
"OAuth2": [
- "emails:read"
+ "deliverability:read"
]
}
],
- "summary": "List emails",
+ "summary": "Diagnose why mail from a domain is not arriving",
"tags": [
- "Emails"
+ "Deliverability"
]
- },
- "post": {
- "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.\n\nRequires the `emails:send` scope — Send emails from your verified domains.",
- "operationId": "sendEmail",
+ }
+ },
+ "/api/v1/deliverability/dmarc": {
+ "get": {
+ "description": "DMARC aggregate (RUA) reports receiving providers have sent about your verified domains, newest reporting window first.\n\nThe only signal on this surface that does not come from us. A report is a receiver saying what it saw arrive claiming to be your domain, from every source — which is how a sender finds out both that their own alignment is broken and that somebody else is sending as them.\n\n`pass_count` counts DMARC ALIGNMENT from `policy_evaluated`, not raw authentication results: a message can pass SPF for a domain that is not the one in its From header, and that is precisely the case DMARC exists to catch.\n\nOnly reports about a domain registered in this project are stored, so a report about a domain you have not added will not appear here.\n\nRequires the `deliverability:read` scope — Check why mail from one of your domains is not arriving.",
+ "operationId": "v1ListDmarcReports",
"parameters": [
{
- "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
- "in": "header",
- "name": "Idempotency-Key",
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ {
+ "description": "How far back to read, by the report's window start. 1-180 days; defaults to 30.",
+ "in": "query",
+ "name": "days",
+ "required": false,
+ "schema": {
+ "description": "How far back to read, by the report's window start. 1-180 days; defaults to 30.",
+ "maximum": 180,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Restrict to reports about one of your domains.",
+ "in": "query",
+ "name": "domain",
"required": false,
"schema": {
- "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
- "maxLength": 255,
+ "description": "Restrict to reports about one of your domains.",
+ "maxLength": 253,
"minLength": 1,
"type": "string"
}
}
],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/SendEmail"
- }
- }
- },
- "required": true
- },
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SendEmailResponse"
- }
- }
- },
- "description": "Email accepted / sent"
- },
- "400": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/DmarcReportV1List"
}
}
},
- "description": "Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows."
+ "description": "Cursor-paginated DMARC aggregate reports"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
- }
- }
- },
- "description": "Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent."
- },
- "409": {
- "content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly."
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "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."
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
- }
- }
- },
- "description": "Internal server error"
- },
- "503": {
- "content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "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."
+ "description": "`internal_error`."
}
},
"security": [
@@ -6522,142 +17396,128 @@
},
{
"OAuth2": [
- "emails:send"
+ "deliverability:read"
]
}
],
- "summary": "Send a single transactional email",
+ "summary": "DMARC aggregate reports for your domains",
"tags": [
- "Emails"
+ "Deliverability"
]
}
},
- "/api/emails/batch": {
- "post": {
- "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.\n\nRequires the `emails:send` scope — Send emails from your verified domains.",
- "operationId": "sendEmailBatch",
+ "/api/v1/deliverability/domains": {
+ "get": {
+ "description": "Sent, delivered, bounced, complained and opened counts split by the RECIPIENT's domain and by UTC day, newest day first.\n\nThis is the axis `diagnose` cannot report: its `recent_delivery` is project-wide, because an email row records no sending domain. A project-wide bounce rate hides the case that matters most — one recipient domain refusing almost everything while the rest is healthy.\n\nThe counts are maintained by an hourly job over a rolling 30-day window, NOT computed on request; `computed_at` on each row says when it was last rebuilt. No rate is published: a rate over three sends is not information, and the counts let you apply your own threshold.\n\nRequires the `deliverability:read` scope — Check why mail from one of your domains is not arriving.",
+ "operationId": "v1ListRecipientDomainStats",
"parameters": [
{
- "in": "header",
- "name": "Idempotency-Key",
+ "in": "query",
+ "name": "limit",
"required": false,
"schema": {
- "maxLength": 255,
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
"minLength": 1,
"type": "string"
}
- }
- ],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/BatchSendBody"
- }
+ },
+ {
+ "description": "How far back to read. 1-30 days; defaults to 30, which is the window the job maintains.",
+ "in": "query",
+ "name": "days",
+ "required": false,
+ "schema": {
+ "description": "How far back to read. 1-30 days; defaults to 30, which is the window the job maintains.",
+ "maximum": 30,
+ "minimum": 1,
+ "type": "integer"
}
},
- "required": true
- },
+ {
+ "description": "Restrict to one recipient domain.",
+ "in": "query",
+ "name": "domain",
+ "required": false,
+ "schema": {
+ "description": "Restrict to one recipient domain.",
+ "maxLength": 253,
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/BatchSendResponse"
- }
- }
- },
- "description": "All entries sent"
- },
- "207": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/BatchSendResponse"
- }
- }
- },
- "description": "Partial success — at least one entry failed"
- },
- "400": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/RecipientDomainStatsV1List"
}
}
},
- "description": "Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows."
+ "description": "Cursor-paginated recipient-domain rollup"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
- }
- }
- },
- "description": "Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent."
- },
- "409": {
- "content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly."
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "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."
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
- }
- }
- },
- "description": "Internal server error"
- },
- "503": {
- "content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "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."
+ "description": "`internal_error`."
}
},
"security": [
@@ -6669,27 +17529,40 @@
},
{
"OAuth2": [
- "emails:send"
+ "deliverability:read"
]
}
],
- "summary": "Send a batch of emails",
+ "summary": "Delivery outcomes per recipient domain",
"tags": [
- "Emails"
+ "Deliverability"
]
}
},
- "/api/emails/{id}": {
+ "/api/v1/domains": {
"get": {
- "description": "Fetch one email along with its delivery events.\n\nRequires the `emails:read` scope — View the emails you have sent and their delivery status.",
- "operationId": "getEmail",
+ "description": "Cursor-paginated list of sending domains, 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`verified` is SES's verdict on the identity and is the field that decides whether mail can leave from this domain. `dkim_verified` is a separate fact — what the DNS health refresh last read for the DKIM records — and the two disagree while a re-check is in flight, so do not treat either as a spelling of the other.\n\nRequires the `domains:read` scope — View your sending domains and their verification status.",
+ "operationId": "v1ListDomains",
"parameters": [
{
- "in": "path",
- "name": "id",
- "required": true,
+ "in": "query",
+ "name": "limit",
+ "required": false,
"schema": {
- "format": "uuid",
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
"type": "string"
}
}
@@ -6699,71 +17572,61 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/EmailGetResponse"
- }
- }
- },
- "description": "Email"
- },
- "400": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/DomainV1List"
}
}
},
- "description": "Validation error"
+ "description": "Sending domain list"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -6775,111 +17638,108 @@
},
{
"OAuth2": [
- "emails:read"
+ "domains:read"
]
}
],
- "summary": "Get a single email",
+ "summary": "List sending domains",
"tags": [
- "Emails"
+ "Domains"
]
- }
- },
- "/api/emails/{id}/schedule": {
- "delete": {
- "description": "Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING.",
- "operationId": "cancelScheduledEmail",
- "parameters": [
- {
- "in": "path",
- "name": "id",
- "required": true,
- "schema": {
- "format": "uuid",
- "type": "string"
+ },
+ "post": {
+ "description": "Register a domain and start SES DKIM verification. The response carries the identity as created — `verified` is false, because nothing is verified until the DKIM records are published in the domain's DNS and SES resolves them. Poll `/api/v1/domains/{id}/verify` after publishing them.\n\n`region` pins the SES region. The first domain a project adds LOCKS the project to that region and every later domain must match it — a project split across regions would have its SES configuration diverge silently.\n\n`stream` assigns the identity to transactional or marketing traffic at creation; omit it to leave the identity serving both. `stream_default` makes it the project's default for that stream and therefore requires `stream` — sending it alone answers `422 validation_error` rather than being ignored.\n\nA host already registered — to this project or to another — answers `409 conflict` when the caller can already send from it and `403 project_access_denied` when it belongs elsewhere. A subdomain whose registrable root is held by a suspended project is refused the same way.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
+ "operationId": "v1CreateDomain",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DomainV1Create"
+ }
}
- }
- ],
+ },
+ "required": true
+ },
"responses": {
- "200": {
+ "201": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/EmailGetResponse"
+ "$ref": "#/components/schemas/DomainV1"
}
}
},
- "description": "Email cancelled"
+ "description": "The registered sending domain, awaiting DNS"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "409": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`conflict` — this domain is already registered to a project you can send from."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
- "409": {
+ "429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Email already past PENDING"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
- "429": {
+ "500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`internal_error`."
},
- "500": {
+ "502": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error` — AWS SES rejected the identity setup (unusable credentials, a missing IAM permission, or a refusal on Amazon's side). No domain row is written, and the request is not at fault: retrying it unchanged will not help."
}
},
"security": [
@@ -6888,131 +17748,116 @@
},
{
"SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:write"
+ ]
}
],
- "summary": "Cancel a scheduled (still-PENDING) email",
+ "summary": "Add a sending domain",
"tags": [
- "Emails"
+ "Domains"
]
}
},
- "/api/lists/{id}/subscribe": {
- "post": {
- "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.",
- "operationId": "subscribeToList",
+ "/api/v1/domains/{id}": {
+ "delete": {
+ "description": "Remove the domain from the project. Refused with `409 conflict` while a template, workflow step or active campaign still sends from an address on this host — repoint those first, or their sends would start failing at SES with nothing here explaining why.\n\nThe underlying SES identity is dropped too, unless another project still holds the same host. Its DKIM keys go with it, so re-adding the domain later mints new records that have to be published again.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
+ "operationId": "v1DeleteDomain",
"parameters": [
{
- "description": "List id.",
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
- "description": "List id.",
- "minLength": 1,
+ "description": "Resource id.",
+ "format": "uuid",
"type": "string"
}
}
],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ListSubscribe"
- }
- }
- },
- "required": true
- },
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ListSubscribeResponse"
- }
- }
- },
- "description": "Contact subscribed, or an existing membership returned unchanged"
- },
- "400": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/DomainV1Deleted"
}
}
},
- "description": "Validation error"
+ "description": "Sending domain removed"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`resource_not_found` — no sending domain with this id belongs to the authenticated project."
},
"409": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "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."
+ "description": "`conflict` — the domain is still in use by a template, workflow step or active campaign."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -7021,121 +17866,104 @@
},
{
"SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:write"
+ ]
}
],
- "summary": "Subscribe a contact to a list",
+ "summary": "Remove a sending domain",
"tags": [
- "Lists"
+ "Domains"
]
- }
- },
- "/api/lists/{id}/unsubscribe": {
- "post": {
- "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.",
- "operationId": "unsubscribeFromList",
+ },
+ "get": {
+ "description": "Fetch one sending domain by id.\n\nRequires the `domains:read` scope — View your sending domains and their verification status.",
+ "operationId": "v1GetDomain",
"parameters": [
{
- "description": "List id.",
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
- "description": "List id.",
- "minLength": 1,
+ "description": "Resource id.",
+ "format": "uuid",
"type": "string"
}
}
],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ListUnsubscribe"
- }
- }
- },
- "required": true
- },
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ListUnsubscribeResponse"
- }
- }
- },
- "description": "Contact unsubscribed"
- },
- "400": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/DomainV1"
}
}
},
- "description": "Validation error"
+ "description": "The sending domain"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`resource_not_found` — no sending domain with this id belongs to the authenticated project."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -7144,96 +17972,106 @@
},
{
"SessionAuth": []
+ },
+ {
+ "OAuth2": [
+ "domains:read"
+ ]
}
],
- "summary": "Unsubscribe a contact from a list",
+ "summary": "Retrieve a sending domain",
"tags": [
- "Lists"
+ "Domains"
]
}
},
- "/api/mailboxes": {
- "get": {
- "description": "Every mailbox on the authenticated project's domains, newest first. Not paginated: a project is capped at ten mailboxes, and the cap counts only those holding — or on their way to holding — a real account (`PROVISIONING`, `ACTIVE`, `SUSPENDED`). `FAILED` rows do not consume the cap but ARE returned here, so a project that has had failed provisions can list more than ten.\n\nThis lists the mailboxes themselves, never their contents: the messages a mailbox has received are not part of the public API and are not covered by this scope.\n\nRequires the `mailboxes:read` scope — View the mailboxes on your domains and their settings.",
- "operationId": "listMailboxes",
+ "/api/v1/domains/{id}/verify": {
+ "post": {
+ "description": "Re-read this domain's state from SES and DNS and return the refreshed document.\n\nThis does NOT perform verification. Verification happens in the domain's own DNS, when its owner publishes the DKIM records SES minted at creation; Amazon decides when they resolve. What this call does is ask SES what it currently sees, re-check SPF and DMARC, and persist the answer — so a caller polling after a DNS change learns the outcome without waiting for the periodic sweep. Calling it on a domain whose records are not published yet is not an error and does not make it verify any sooner.\n\nA POST rather than a GET because it writes: the refreshed state is persisted, and a verified/unverified transition notifies the project.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.",
+ "operationId": "v1VerifyDomain",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "items": {
- "$ref": "#/components/schemas/Mailbox"
- },
- "type": "array"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/DomainV1"
}
}
},
- "description": "Mailbox list"
+ "description": "The sending domain, as SES and DNS now report it"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no sending domain with this id belongs to the authenticated project."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -7245,378 +18083,344 @@
},
{
"OAuth2": [
- "mailboxes:read"
+ "domains:write"
]
}
],
- "summary": "List mailboxes",
+ "summary": "Refresh a sending domain's verification state",
"tags": [
- "Mailboxes"
+ "Domains"
]
- },
+ }
+ },
+ "/api/v1/email-validations": {
"post": {
- "description": "Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified.\n\nThree consequences worth knowing before you call it:\n\n- **It changes how your domain's mail is routed.** The first mailbox on a domain turns receiving on for that domain, so mail addressed there starts arriving at Sendly instead of wherever it went before.\n- **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS.\n- **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one.\n\nRetrying a failed provision with the same address reclaims the failed row rather than answering 409 — but only for the same project and the same domain.\n\n`quotaBytes` is accepted by the schema and REFUSED with a 400. Quotas are not implemented, and the field is still parsed so that asking for one is an error rather than a silently dropped key.\n\nRequires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains.",
- "operationId": "createMailbox",
+ "description": "Check up to 50 addresses for whether they can receive mail, and for the signals that make one worth mailing. Billed per address.\n\nThe response publishes a `verdict` alongside the flags it was drawn from. Branch on the verdict: the two obvious readings of the flags are both wrong — a free-provider address (`is_personal`) and a role mailbox (`is_role_address`) are ordinary, deliverable addresses that real customers use, and refusing them would shrink a list for no reason. `is_disposable` is the only flag that lowers a verdict.\n\n`unknown` means DNS did not answer in time, so that address was NOT checked. It is a separate value from `undeliverable` on purpose — acting on the two together deletes live contacts over a network hiccup.\n\nThe 50-address ceiling is a latency bound, not a payload one: every distinct DOMAIN in the batch costs a DNS round trip. To validate a whole list, use `POST /api/v1/lists/{id}/validation-runs`, which runs as a background job.\n\nRequires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address.",
+ "operationId": "v1ValidateEmails",
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CreateMailboxBody"
+ "$ref": "#/components/schemas/EmailValidationBatchRequestV1"
}
}
},
"required": true
},
"responses": {
- "201": {
- "content": {
- "application/json": {
- "schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Mailbox"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
- }
- }
- },
- "description": "Mailbox provisioned"
- },
- "400": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/EmailValidationBatchV1"
}
}
},
- "description": "Validation error"
+ "description": "One verdict per address, in the order they were given"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
- }
- }
- },
- "description": "Forbidden — insufficient permissions or project disabled"
- },
- "404": {
- "content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "409": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "The address already exists, the domain is not verified, or the project is at its 10-mailbox limit."
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
- }
- }
- },
- "description": "Internal server error"
- },
- "502": {
- "content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried."
+ "description": "`internal_error`."
}
},
"security": [
+ {
+ "ApiKeyAuth": []
+ },
{
"SessionAuth": []
},
{
"OAuth2": [
- "mailboxes:write"
+ "validation:write"
]
}
],
- "summary": "Create a mailbox",
+ "summary": "Validate a batch of email addresses",
"tags": [
- "Mailboxes"
+ "Validation"
]
}
},
- "/api/mailboxes/{id}": {
- "delete": {
- "description": "Delete a mailbox and the mail account behind it. **Every message it holds is erased**, and Sendly keeps no other copy — this is not recoverable from the dashboard or by support. Mail sent to the address afterwards is rejected.\n\nRequires an admin of the project.\n\nRequires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains.",
- "operationId": "deleteMailbox",
+ "/api/v1/emails": {
+ "post": {
+ "description": "Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`.\n\nThis is the send to reach for when you need to know what happened. The legacy `POST /api/emails` answers with row ids and no status, so telling an accepted send from a refused one costs a second request; here the receipt carries `status`, and `from` reports the sender actually used — which is worth reading, since a template may have supplied it.\n\nExactly ONE recipient. A single receipt cannot describe a fan-out, so `to` takes one address: use `cc`/`bcc` to copy others on the same message, and `POST /api/emails/batch` to send different ones.\n\n`202 Accepted` is the success answer, and `PENDING` the usual `status`: the message is queued for the sending pipeline, not yet handed to the provider. Later states (`DELIVERED`, `BOUNCED`, …) arrive by webhook.\n\nAn optional `Idempotency-Key` header (1–255 chars, 24h TTL) makes a retry safe: the first request wins and a retry carrying the same key AND body replays its receipt. Reusing a key with a different body answers 422 rather than serving another request's result.\n\nRequires the `emails:send` scope — Send emails from your verified domains.",
+ "operationId": "v1SendEmail",
"parameters": [
{
- "in": "path",
- "name": "id",
- "required": true,
+ "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
+ "in": "header",
+ "name": "Idempotency-Key",
+ "required": false,
"schema": {
- "format": "uuid",
+ "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
+ "maxLength": 255,
+ "minLength": 1,
"type": "string"
- }
- }
- ],
- "responses": {
- "200": {
- "content": {
- "application/json": {
- "schema": {
- "properties": {
- "data": {
- "properties": {
- "deleted": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "deleted"
- ],
- "type": "object"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
- }
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SendEmailV1"
}
- },
- "description": "Mailbox deleted"
+ }
},
- "400": {
+ "required": true
+ },
+ "responses": {
+ "202": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/EmailV1"
}
}
},
- "description": "Validation error"
+ "description": "Email queued"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_disabled`, `domain_not_allowed` — the `from` address does not resolve to a verified domain on this project — or `content_rejected`: automated content review flagged the message and it was not sent."
},
"404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`resource_not_found` — `template` names a template that does not belong to this project."
+ },
+ "409": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — the body did not match the schema; or `idempotency_key_reused` — this `Idempotency-Key` was already spent on a request with a different body. Send a new key."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
+ },
+ "503": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "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."
}
},
"security": [
+ {
+ "ApiKeyAuth": []
+ },
{
"SessionAuth": []
},
{
"OAuth2": [
- "mailboxes:write"
+ "emails:send"
]
}
],
- "summary": "Delete a mailbox",
+ "summary": "Send a transactional email",
"tags": [
- "Mailboxes"
+ "Emails"
]
- },
- "get": {
- "description": "One mailbox, with the IMAP and SMTP host/port/username a mail client needs. The password is not included: mailbox credentials are app passwords, created separately and shown once.\n\nRequires the `mailboxes:read` scope — View the mailboxes on your domains and their settings.",
- "operationId": "getMailbox",
- "parameters": [
- {
- "in": "path",
- "name": "id",
- "required": true,
- "schema": {
- "format": "uuid",
- "type": "string"
+ }
+ },
+ "/api/v1/emails/test": {
+ "post": {
+ "description": "Prove that sending works — before any domain, DNS record or verification exists.\n\nThe message is sent FROM this project's sandbox address (`sandbox_address` on `GET /api/v1/projects`) and can only reach ONE recipient: the project owner's own verified account email, which is also what `to` defaults to. Naming any other recipient answers 403, and naming a `from` answers 422 — the sender is resolved server-side and a request that expects a different one is refused rather than quietly re-addressed.\n\nThat restriction is the reason `emails:test` is a separate, non-sensitive scope: a call under it cannot put mail in a stranger's inbox, so it can sit in a default grant where `emails:send` may not. It is not a way to send real mail cheaply — use `POST /api/v1/emails` for that.\n\nSandbox sends are capped per project per day, and the response's `sandbox: true` marks the receipt so a relayed summary cannot pass a test send off as a real one.\n\nRequires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox.",
+ "operationId": "v1SendTestEmail",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SendTestEmailV1"
+ }
}
- }
- ],
+ },
+ "required": true
+ },
"responses": {
- "200": {
+ "202": {
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/MailboxDetail"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/EmailTestV1"
}
}
},
- "description": "Mailbox with connection settings"
+ "description": "Test email queued"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_disabled`, or `forbidden` — the recipient is not the project owner's own verified account email (including the case where that address is not verified yet, so there is no default recipient), or `content_rejected` from automated content review."
},
- "403": {
+ "409": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`conflict` — this project has no sandbox sender. The handle is derived from the project owner's email and this project has no owner; no retry fixes it."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — the body did not match the schema, most often because it named a `from`. A test send always comes from the sandbox address."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
+ },
+ "503": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`content_review_unavailable` — content review could not run for this new account. Safe to retry."
}
},
"security": [
@@ -7628,119 +18432,116 @@
},
{
"OAuth2": [
- "mailboxes:read"
+ "emails:test"
]
}
],
- "summary": "Get a mailbox",
+ "summary": "Send a sandbox test email",
"tags": [
- "Mailboxes"
+ "Emails"
]
}
},
- "/api/mailboxes/{id}/app-passwords": {
+ "/api/v1/events": {
"get": {
- "description": "Every app password on the mailbox — name, protocols, last four characters and last use. The secrets themselves are stored hashed and are not retrievable here or anywhere else; a password you have lost is replaced, not recovered.\n\nRequires the `mailboxes:read` scope — View the mailboxes on your domains and their settings.",
- "operationId": "listAppPasswords",
+ "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.",
+ "operationId": "v1ListEvents",
"parameters": [
{
- "in": "path",
- "name": "id",
- "required": true,
+ "in": "query",
+ "name": "limit",
+ "required": false,
"schema": {
- "format": "uuid",
- "type": "string"
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
+ "type": "string"
}
- }
- ],
- "responses": {
- "200": {
- "content": {
- "application/json": {
- "schema": {
- "properties": {
- "data": {
- "items": {
- "$ref": "#/components/schemas/AppPassword"
- },
- "type": "array"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
- }
- }
- },
- "description": "App password list"
},
- "400": {
+ {
+ "description": "Return only events with this exact name.",
+ "in": "query",
+ "name": "event_name",
+ "required": false,
+ "schema": {
+ "description": "Return only events with this exact name.",
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/EventV1List"
}
}
},
- "description": "Validation error"
+ "description": "Event list"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -7752,34 +18553,23 @@
},
{
"OAuth2": [
- "mailboxes:read"
+ "events:read"
]
}
],
- "summary": "List a mailbox's app passwords",
+ "summary": "List events",
"tags": [
- "Mailboxes"
+ "Events"
]
},
"post": {
- "description": "Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it.\n\n**The secret is not in the response.** A delegated caller receives `revealUrl` — a single-use link that shows the password once in a browser, to a signed-in project admin. The connection that created the password cannot open its own link, and the link is spent by the first attempt to open it, successful or not.\n\nThat is deliberate and not a limitation to work around: an app password is a live mail credential that a client authenticates with directly, it outlives the grant that created it, and it is revoked from a different screen. Returning it inline would place a working mail credential in an agent's context, its transcript, and every log that transcript reaches.\n\nRequires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection.\n\nRequires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains.",
- "operationId": "createAppPassword",
- "parameters": [
- {
- "in": "path",
- "name": "id",
- "required": true,
- "schema": {
- "format": "uuid",
- "type": "string"
- }
- }
- ],
+ "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\nSending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else.\n\nRequires the `events:write` scope — Record custom events for your contacts.",
+ "operationId": "v1TrackEvent",
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CreateAppPassword"
+ "$ref": "#/components/schemas/EventTrackV1"
}
}
},
@@ -7790,255 +18580,208 @@
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/AppPasswordReveal"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/EventV1"
}
}
},
- "description": "App password created; the secret is behind the one-time link"
+ "description": "Event recorded"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no contact with this id in the authenticated project."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
+ {
+ "ApiKeyAuth": []
+ },
{
"SessionAuth": []
},
{
"OAuth2": [
- "mailboxes:write"
+ "events:write"
]
}
],
- "summary": "Create an app password",
+ "summary": "Record an event",
"tags": [
- "Mailboxes"
+ "Events"
]
}
},
- "/api/mailboxes/{id}/app-passwords/{passwordId}": {
- "delete": {
- "description": "Revoke one app password. Any mail client still configured with it stops authenticating immediately — there is no grace period — and the mailbox and its messages are untouched.\n\nRequires an admin of the project. An API key is refused with 401.\n\nRequires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains.",
- "operationId": "revokeAppPassword",
- "parameters": [
- {
- "in": "path",
- "name": "id",
- "required": true,
- "schema": {
- "format": "uuid",
- "type": "string"
- }
- },
- {
- "in": "path",
- "name": "passwordId",
- "required": true,
- "schema": {
- "format": "uuid",
- "type": "string"
- }
- }
- ],
+ "/api/v1/events/names": {
+ "get": {
+ "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.",
+ "operationId": "v1ListEventNames",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "properties": {
- "revoked": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "revoked"
- ],
- "type": "object"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
- }
- }
- },
- "description": "App password revoked"
- },
- "400": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/EventNamesV1"
}
}
},
- "description": "Validation error"
+ "description": "Event names"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
+ {
+ "ApiKeyAuth": []
+ },
{
"SessionAuth": []
},
{
"OAuth2": [
- "mailboxes:write"
+ "events:read"
]
}
],
- "summary": "Revoke an app password",
+ "summary": "List event names",
"tags": [
- "Mailboxes"
+ "Events"
]
}
},
- "/api/projects/{id}/api-keys": {
+ "/api/v1/events/stats": {
"get": {
- "description": "Returns every key on the project, revoked ones included — filter on `revokedAt` to show only live keys. Never returns a token or its hash: `lastFour` is the only fragment of the secret that survives creation.\n\nRequires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do.",
- "operationId": "listApiKeys",
+ "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.",
+ "operationId": "v1GetEventStats",
"parameters": [
{
- "description": "Project id.",
- "in": "path",
- "name": "id",
- "required": true,
+ "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
+ "in": "query",
+ "name": "from",
+ "required": false,
"schema": {
- "description": "Project id.",
- "type": "string"
+ "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "description": "End of the window (ISO 8601). Defaults to now.",
+ "in": "query",
+ "name": "to",
+ "required": false,
+ "schema": {
+ "description": "End of the window (ISO 8601). Defaults to now.",
+ "format": "date-time",
+ "type": [
+ "string",
+ "null"
+ ]
}
}
],
@@ -8047,366 +18790,297 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ApiKeyListResponse"
- }
- }
- },
- "description": "API key list"
- },
- "400": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/EventStatsV1"
}
}
},
- "description": "Validation error"
+ "description": "Event counts"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
+ {
+ "ApiKeyAuth": []
+ },
{
"SessionAuth": []
},
{
"OAuth2": [
- "api-keys:read"
+ "events:read"
]
}
],
- "summary": "List API keys for a project",
+ "summary": "Retrieve event counts",
"tags": [
- "API Keys"
+ "Events"
]
- },
- "post": {
- "description": "Mint a new API key on the project. The token is NOT returned — the response carries the key's metadata plus a one-time `revealUrl` that only a signed-in dashboard session can open.\n\n**Scope attenuation:** a key created with a delegated credential (an OAuth token or another API key) may not carry a scope that credential does not itself hold. A request that asks for more is refused with `400 SCOPE_ESCALATION` rather than quietly narrowed, so the mistake is reported where it was made instead of surfacing later as an unexplained 403. Naming only `permission` counts as asking for every scope that permission implies.\n\nRequires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app.",
- "operationId": "createApiKey",
+ }
+ },
+ "/api/v1/lists": {
+ "get": {
+ "description": "Cursor-paginated list of subscriber lists, 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` counts memberships in EVERY status — `PENDING` and `UNSUBSCRIBED` included — so it is the size of the membership table for this list, not the number of people currently subscribed. Read `GET /api/lists/{id}/members?status=CONFIRMED` when you want the latter.\n\nRequires the `lists:read` scope — View your subscriber lists and who is on them.",
+ "operationId": "v1ListLists",
"parameters": [
{
- "description": "Project id.",
- "in": "path",
- "name": "id",
- "required": true,
+ "in": "query",
+ "name": "limit",
+ "required": false,
"schema": {
- "description": "Project id.",
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
"type": "string"
}
}
],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/CreateApiKeyBody"
- }
- }
- },
- "required": true
- },
"responses": {
- "201": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "allOf": [
- {
- "$ref": "#/components/schemas/ApiKey"
- },
- {
- "properties": {
- "revealExpiresAt": {
- "description": "When the reveal link stops working. Create or rotate again to get a new one.",
- "format": "date-time",
- "type": "string"
- },
- "revealUrl": {
- "description": "A one-time, session-authenticated URL where the person who owns this project can see the secret. The secret itself is never returned to an API or agent caller: opening this link requires a signed-in dashboard session, so the credential that created the key cannot redeem it. Single use — the first successful open consumes it.",
- "format": "uri",
- "type": "string"
- }
- },
- "required": [
- "revealUrl",
- "revealExpiresAt"
- ],
- "type": "object"
- }
- ],
- "description": "An API key's metadata. Never carries the token or its hash — `lastFour` is the only surviving fragment of the secret once the key has been created."
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/ListV1List"
}
}
},
- "description": "API key created; the secret is behind the reveal link."
+ "description": "Subscriber lists"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
{
- "SessionAuth": []
+ "ApiKeyAuth": []
},
{
- "OAuth2": [
- "api-keys:write"
- ]
- }
- ],
- "summary": "Create an API key",
- "tags": [
- "API Keys"
- ]
- }
- },
- "/api/projects/{id}/api-keys/{keyId}": {
- "delete": {
- "description": "Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project.\n\nRequires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app.",
- "operationId": "revokeApiKey",
- "parameters": [
- {
- "description": "Project id.",
- "in": "path",
- "name": "id",
- "required": true,
- "schema": {
- "description": "Project id.",
- "type": "string"
- }
+ "SessionAuth": []
},
{
- "description": "API key id.",
- "in": "path",
- "name": "keyId",
- "required": true,
- "schema": {
- "description": "API key id.",
- "type": "string"
- }
- }
- ],
- "responses": {
- "200": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/SuccessEmpty"
- }
+ "OAuth2": [
+ "lists:read"
+ ]
+ }
+ ],
+ "summary": "List subscriber lists",
+ "tags": [
+ "Lists"
+ ]
+ },
+ "post": {
+ "description": "Create an empty subscriber list. `member_count` on the response is 0 because the list has just been created — add contacts with `POST /api/lists/{id}/subscribe`.\n\n**`double_opt_in` does not make Sendly send anything.** With it on, `POST /api/lists/{id}/subscribe` creates the membership as `PENDING` and returns a `confirm_token`; delivering `/api/lists/confirm-subscription?token=` to the contact is YOUR job. A list that turns the flag on without sending that link collects pending memberships and confirms none of them.\n\n`description`, `confirmation_template_id` and `redirect_url` accept `null`, which means the same as omitting them: the field is left unset.\n\nRequires the `lists:write` scope — Create, rename, and delete your subscriber lists.",
+ "operationId": "v1CreateList",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ListV1Create"
}
- },
- "description": "API key revoked"
+ }
},
- "400": {
+ "required": true
+ },
+ "responses": {
+ "201": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/ListV1"
}
}
},
- "description": "Validation error"
+ "description": "The created list"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
+ {
+ "ApiKeyAuth": []
+ },
{
"SessionAuth": []
},
{
"OAuth2": [
- "api-keys:write"
+ "lists:write"
]
}
],
- "summary": "Revoke an API key",
+ "summary": "Create a subscriber list",
"tags": [
- "API Keys"
+ "Lists"
]
}
},
- "/api/projects/{id}/api-keys/{keyId}/rotate": {
- "post": {
- "description": "Replace the key's secret in place, keeping its id, name and scopes. The PREVIOUS secret stops authenticating immediately — there is no overlap window — so anything still using it starts failing on its next request. As with creation, the new secret is not returned: the response carries `lastFour` and a one-time `revealUrl`. A revoked key cannot be rotated (`400 KEY_REVOKED`).\n\nRequires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app.",
- "operationId": "rotateApiKey",
+ "/api/v1/lists/{id}": {
+ "delete": {
+ "description": "Delete the list and, by cascade, every membership on it. Those memberships are the consent record: an `UNSUBSCRIBED` row is the evidence that someone opted out, and it goes with the list — re-creating the list and re-importing the same addresses will not find their opt-outs waiting. The emails already sent are untouched.\n\nRequires the `lists:write` scope — Create, rename, and delete your subscriber lists.",
+ "operationId": "v1DeleteList",
"parameters": [
{
- "description": "Project id.",
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
- "description": "Project id.",
- "type": "string"
- }
- },
- {
- "description": "API key id.",
- "in": "path",
- "name": "keyId",
- "required": true,
- "schema": {
- "description": "API key id.",
+ "description": "Resource id.",
+ "format": "uuid",
"type": "string"
}
}
@@ -8416,159 +19090,103 @@
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "properties": {
- "lastFour": {
- "type": "string"
- },
- "revealExpiresAt": {
- "description": "When the reveal link stops working. Create or rotate again to get a new one.",
- "format": "date-time",
- "type": "string"
- },
- "revealUrl": {
- "description": "A one-time, session-authenticated URL where the person who owns this project can see the secret. The secret itself is never returned to an API or agent caller: opening this link requires a signed-in dashboard session, so the credential that created the key cannot redeem it. Single use — the first successful open consumes it.",
- "format": "uri",
- "type": "string"
- }
- },
- "required": [
- "lastFour",
- "revealUrl",
- "revealExpiresAt"
- ],
- "type": "object"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/ListV1Deleted"
}
}
},
- "description": "API key rotated; the new secret is behind the reveal link."
+ "description": "List deleted"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no list with this id belongs to the authenticated project."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
+ {
+ "ApiKeyAuth": []
+ },
{
"SessionAuth": []
},
{
"OAuth2": [
- "api-keys:write"
+ "lists:write"
]
}
],
- "summary": "Rotate an API key's secret",
+ "summary": "Delete a subscriber list",
"tags": [
- "API Keys"
+ "Lists"
]
- }
- },
- "/api/suppression": {
+ },
"get": {
- "description": "Cursor-paginated list of suppressed addresses. Filter by `reason`.\n\nRequires the `suppression:read` scope — View the addresses on your suppression list.",
- "operationId": "listSuppressions",
+ "description": "Fetch one list by id, with the same status-agnostic `member_count` the collection returns.\n\nRequires the `lists:read` scope — View your subscriber lists and who is on them.",
+ "operationId": "v1GetList",
"parameters": [
{
- "in": "query",
- "name": "limit",
- "required": false,
- "schema": {
- "default": 50,
- "maximum": 100,
- "minimum": 1,
- "type": "integer"
- }
- },
- {
- "in": "query",
- "name": "cursor",
- "required": false,
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "reason",
- "required": false,
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
"schema": {
- "enum": [
- "HARD_BOUNCE",
- "COMPLAINT",
- "MANUAL",
- "UNSUBSCRIBE"
- ],
+ "description": "Resource id.",
+ "format": "uuid",
"type": "string"
}
}
@@ -8578,61 +19196,71 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SuppressionListResponse"
+ "$ref": "#/components/schemas/ListV1"
}
}
},
- "description": "Suppression list"
+ "description": "The list"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no list with this id belongs to the authenticated project."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -8644,88 +19272,111 @@
},
{
"OAuth2": [
- "suppression:read"
+ "lists:read"
]
}
],
- "summary": "List suppressed emails",
+ "summary": "Retrieve a subscriber list",
"tags": [
- "Suppression"
+ "Lists"
]
},
- "post": {
- "description": "The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers.\n\nRequires the `suppression:write` scope — Add and remove addresses on your suppression list.",
- "operationId": "addSuppression",
+ "patch": {
+ "description": "Partial update. Omitted fields are left alone, and `null` for `description`, `confirmation_template_id` or `redirect_url` is treated the same as omitting them — this surface cannot clear a field back to empty yet.\n\n**`double_opt_in` does not make Sendly send anything.** With it on, `POST /api/lists/{id}/subscribe` creates the membership as `PENDING` and returns a `confirm_token`; delivering `/api/lists/confirm-subscription?token=` to the contact is YOUR job. A list that turns the flag on without sending that link collects pending memberships and confirms none of them.\n\nTurning `double_opt_in` on affects only memberships created afterwards. Existing `CONFIRMED` memberships stay confirmed: those contacts consented under the rule in force when they subscribed, and demoting them would revoke a consent record rather than collect one.\n\nRequires the `lists:write` scope — Create, rename, and delete your subscriber lists.",
+ "operationId": "v1UpdateList",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/AddSuppression"
+ "$ref": "#/components/schemas/ListV1Update"
}
}
},
"required": true
},
"responses": {
- "201": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Suppression"
+ "$ref": "#/components/schemas/ListV1"
}
}
},
- "description": "Suppression added"
+ "description": "The updated list"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no list with this id belongs to the authenticated project."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -8737,85 +19388,103 @@
},
{
"OAuth2": [
- "suppression:write"
+ "lists:write"
]
}
],
- "summary": "Manually add an email to the suppression list",
+ "summary": "Update a subscriber list",
"tags": [
- "Suppression"
+ "Lists"
]
}
},
- "/api/suppression/{email}": {
- "delete": {
- "description": "Idempotent. Silently no-ops if the suppression doesn't exist.\n\nRequires the `suppression:write` scope — Add and remove addresses on your suppression list.",
- "operationId": "removeSuppression",
+ "/api/v1/lists/{id}/validation-runs": {
+ "post": {
+ "description": "Start a background run over every contact on the list and answer immediately with the run. Poll `GET /api/v1/validation-runs/{id}` for progress and read the verdicts from its `/results` page.\n\nThis VALIDATES and changes nothing: no membership is unsubscribed, no contact is deleted. What to do about an `undeliverable` address is your decision, and a run that acted on its own findings would be acting on a DNS lookup that can also answer `unknown`.\n\nA second run while one is already in flight for the same list is refused with 409 rather than queued: two runs would bill the same addresses twice.\n\nRequires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address.",
+ "operationId": "v1StartListValidationRun",
"parameters": [
{
- "description": "URL-encoded email address",
+ "description": "Resource id.",
"in": "path",
- "name": "email",
+ "name": "id",
"required": true,
"schema": {
- "description": "URL-encoded email address",
+ "description": "Resource id.",
+ "format": "uuid",
"type": "string"
}
}
],
"responses": {
- "204": {
- "description": "Suppression removed"
- },
- "400": {
+ "202": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/EmailValidationRunV1"
}
}
},
- "description": "Validation error"
+ "description": "The run, accepted and queued"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
+ },
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`resource_not_found` — no list with this id belongs to the authenticated project."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -8827,90 +19496,90 @@
},
{
"OAuth2": [
- "suppression:write"
+ "validation:write"
]
}
],
- "summary": "Remove an email from the suppression list",
+ "summary": "Validate every address on a list",
"tags": [
- "Suppression"
+ "Validation"
]
- },
+ }
+ },
+ "/api/v1/projects": {
"get": {
- "description": "Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded.\n\nRequires the `suppression:read` scope — View the addresses on your suppression list.",
- "operationId": "checkSuppression",
- "parameters": [
- {
- "description": "URL-encoded email address",
- "in": "path",
- "name": "email",
- "required": true,
- "schema": {
- "description": "URL-encoded email address",
- "type": "string"
- }
- }
- ],
+ "description": "The project the presented credential is scoped to — resolved from the API key, or from the `x-project-id` header for a session or delegated token. Singular despite the plural path, like `/api/v1/usage`: every v1 operation acts on exactly one project.\n\n`sandbox_address` is this project's quick-start sender. It works with no domain setup, but only to the project owner's own verified address and under a daily cap — it is how you prove sending works end to end before any DNS exists. It is null when no handle can be derived (a project with no owner).\n\nTo enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`.\n\nRequires the `projects:read` scope — View your projects and their settings.",
+ "operationId": "v1GetProject",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SuppressionCheckResponse"
+ "$ref": "#/components/schemas/ProjectV1"
}
}
},
- "description": "Suppression check result"
+ "description": "The authenticated project"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — the project was deleted between authentication and this read."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -8922,20 +19591,20 @@
},
{
"OAuth2": [
- "suppression:read"
+ "projects:read"
]
}
],
- "summary": "Check whether an email is suppressed",
+ "summary": "Retrieve the authenticated project",
"tags": [
- "Suppression"
+ "Projects"
]
}
},
- "/api/templates": {
+ "/api/v1/segments": {
"get": {
- "description": "Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject.\n\nRequires the `templates:read` scope — View your email templates.",
- "operationId": "listTemplates",
+ "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.",
+ "operationId": "v1ListSegments",
"parameters": [
{
"in": "query",
@@ -8949,106 +19618,77 @@
}
},
{
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
"in": "query",
- "name": "cursor",
+ "name": "after",
"required": false,
"schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
"minLength": 1,
"type": "string"
}
- },
- {
- "in": "query",
- "name": "search",
- "required": false,
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "type",
- "required": false,
- "schema": {
- "enum": [
- "MARKETING",
- "TRANSACTIONAL",
- "HEADLESS"
- ],
- "type": "string"
- }
}
],
- "responses": {
- "200": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/TemplateListResponse"
- }
- }
- },
- "description": "Template list"
- },
- "400": {
+ "responses": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/SegmentV1List"
}
}
},
- "description": "Validation error"
+ "description": "Segment list"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -9060,23 +19700,23 @@
},
{
"OAuth2": [
- "templates:read"
+ "segments:read"
]
}
],
- "summary": "List templates",
+ "summary": "List segments",
"tags": [
- "Templates"
+ "Segments"
]
},
"post": {
- "description": "Create a new email template. The `from` domain must already be verified for the project.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
- "operationId": "createTemplate",
+ "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.",
+ "operationId": "v1CreateSegment",
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CreateTemplate"
+ "$ref": "#/components/schemas/SegmentV1Create"
}
}
},
@@ -9087,86 +19727,71 @@
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Template"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/SegmentV1"
}
}
},
- "description": "Template created"
+ "description": "Segment created"
},
"400": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`validation_error` — a `DYNAMIC` segment was submitted without a `condition`."
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -9178,26 +19803,28 @@
},
{
"OAuth2": [
- "templates:write"
+ "segments:write"
]
}
],
- "summary": "Create a template",
+ "summary": "Create a segment",
"tags": [
- "Templates"
+ "Segments"
]
}
},
- "/api/templates/{id}": {
+ "/api/v1/segments/{id}": {
"delete": {
- "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.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
- "operationId": "deleteTemplate",
+ "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.",
+ "operationId": "v1DeleteSegment",
"parameters": [
{
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
+ "description": "Resource id.",
"format": "uuid",
"type": "string"
}
@@ -9208,81 +19835,81 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/IdResponse"
+ "$ref": "#/components/schemas/SegmentV1Deleted"
}
}
},
- "description": "Template deleted"
+ "description": "Segment deleted"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no segment with this id belongs to the authenticated project."
},
- "404": {
+ "409": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`conflict` — the segment is still used by one or more active campaigns."
},
- "409": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Template still in use"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -9294,24 +19921,26 @@
},
{
"OAuth2": [
- "templates:write"
+ "segments:write"
]
}
],
- "summary": "Delete a template",
+ "summary": "Delete a segment",
"tags": [
- "Templates"
+ "Segments"
]
},
"get": {
- "description": "Requires the `templates:read` scope — View your email templates.",
- "operationId": "getTemplate",
+ "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.",
+ "operationId": "v1GetSegment",
"parameters": [
{
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
+ "description": "Resource id.",
"format": "uuid",
"type": "string"
}
@@ -9322,86 +19951,71 @@
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Template"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
+ "$ref": "#/components/schemas/SegmentV1"
}
}
},
- "description": "Template"
+ "description": "The segment"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no segment with this id belongs to the authenticated project."
},
- "404": {
+ "422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -9413,24 +20027,26 @@
},
{
"OAuth2": [
- "templates:read"
+ "segments:read"
]
}
],
- "summary": "Get a template",
+ "summary": "Retrieve a segment",
"tags": [
- "Templates"
+ "Segments"
]
},
"patch": {
- "description": "Update one or more fields. If `from` changes, the new domain must already be verified.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
- "operationId": "updateTemplate",
+ "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.",
+ "operationId": "v1UpdateSegment",
"parameters": [
{
+ "description": "Resource id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
+ "description": "Resource id.",
"format": "uuid",
"type": "string"
}
@@ -9440,7 +20056,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateTemplate"
+ "$ref": "#/components/schemas/SegmentV1Update"
}
}
},
@@ -9451,96 +20067,71 @@
"content": {
"application/json": {
"schema": {
- "properties": {
- "data": {
- "$ref": "#/components/schemas/Template"
- },
- "success": {
- "enum": [
- true
- ],
- "type": "boolean"
- }
- },
- "required": [
- "success",
- "data"
- ],
- "type": "object"
- }
- }
- },
- "description": "Updated template"
- },
- "400": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/SegmentV1"
}
}
},
- "description": "Validation error"
+ "description": "The updated segment"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Resource not found"
+ "description": "`resource_not_found` — no segment with this id belongs to the authenticated project."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
@@ -9552,273 +20143,293 @@
},
{
"OAuth2": [
- "templates:write"
+ "segments:write"
]
}
],
- "summary": "Update a template",
+ "summary": "Update a segment",
"tags": [
- "Templates"
+ "Segments"
]
}
},
- "/api/track": {
- "post": {
- "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.\n\nRequires the `events:write` scope — Record custom events for your contacts.",
- "operationId": "trackEvent",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/TrackEvent"
- }
+ "/api/v1/segments/{id}/contacts": {
+ "get": {
+ "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.",
+ "operationId": "v1ListSegmentContacts",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
}
},
- "required": true
- },
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/TrackEventResponse"
+ "$ref": "#/components/schemas/SegmentContactV1List"
}
}
},
- "description": "Event tracked"
+ "description": "Segment member list"
},
- "400": {
+ "401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation error"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`resource_not_found` — no segment with this id belongs to the authenticated project."
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
{
"ApiKeyAuth": []
},
+ {
+ "SessionAuth": []
+ },
{
"OAuth2": [
- "events:write"
+ "segments:read"
]
}
],
- "summary": "Track a custom event for a contact",
+ "summary": "List the contacts in a segment",
"tags": [
- "Events"
+ "Segments"
]
}
},
- "/api/users/me/projects": {
- "post": {
- "description": "Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201.\n\nPreconditions the route enforces before writing: the caller's email must be verified, the caller must be under their cap on active (non-disabled) owned projects, and the call is rate-limited per user.\n\nRequires the `projects:write` scope — Create new projects on your account.",
- "operationId": "createProject",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "properties": {
- "name": {
- "maxLength": 100,
- "minLength": 1,
- "type": "string"
- },
- "sesRegion": {
- "description": "AWS SES region for the project. Once a domain is added the region is locked and cannot be changed.",
- "enum": [
- "us-east-1",
- "us-west-2",
- "eu-west-1"
- ],
- "type": "string"
- }
- },
- "required": [
- "name"
- ],
- "type": "object"
- }
+ "/api/v1/suppressions": {
+ "get": {
+ "description": "Cursor-paginated list of suppressed addresses, 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\nA cursor is bound to the filters that minted it. Changing `reason` while reusing a cursor answers `422 validation_error` rather than returning a page that belongs to neither query — drop the cursor and start again from the first page.\n\nRequires the `suppression:read` scope — View the addresses on your suppression list.",
+ "operationId": "v1ListSuppressions",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
}
},
- "required": true
- },
- "responses": {
- "201": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ProjectRecord"
- }
- }
- },
- "description": "Project created"
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
+ "type": "string"
+ }
},
- "400": {
+ {
+ "description": "Filter to one reason. Omit for every suppressed address.",
+ "in": "query",
+ "name": "reason",
+ "required": false,
+ "schema": {
+ "description": "Filter to one reason. Omit for every suppressed address.",
+ "enum": [
+ "HARD_BOUNCE",
+ "COMPLAINT",
+ "MANUAL",
+ "UNSUBSCRIBE"
+ ],
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/SuppressionV1List"
}
}
},
- "description": "Validation error"
+ "description": "Suppression list"
},
"401": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Unauthorized — missing or invalid auth"
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
"403": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Forbidden — insufficient permissions or project disabled"
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"422": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Validation failed — request body or query parameters did not match the schema"
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Rate limit or billing limit exceeded"
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
- "application/json": {
+ "application/problem+json": {
"schema": {
- "$ref": "#/components/schemas/Error"
+ "$ref": "#/components/schemas/Problem"
}
}
},
- "description": "Internal server error"
+ "description": "`internal_error`."
}
},
"security": [
+ {
+ "ApiKeyAuth": []
+ },
{
"SessionAuth": []
},
{
"OAuth2": [
- "projects:write"
+ "suppression:read"
]
}
],
- "summary": "Create a project",
+ "summary": "List suppressed addresses",
"tags": [
- "Projects"
+ "Suppression"
]
- }
- },
- "/api/v1/analytics/campaigns": {
- "get": {
- "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.",
- "operationId": "v1GetCampaignAnalytics",
- "parameters": [
- {
- "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
- "in": "query",
- "name": "from",
- "required": false,
- "schema": {
- "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
- "format": "date-time",
- "type": [
- "string",
- "null"
- ]
+ },
+ "post": {
+ "description": "Add an address to this project's suppression list, so no further send reaches it.\n\nIdempotent: suppressing an already-suppressed address answers `201` with the EXISTING record rather than `409`. The first `reason` and `source` win, because a later manual entry must not overwrite what an SES bounce recorded.\n\n`source` is NOT accepted in the body — it is derived from the credential (`API` for an API key, `DASHBOARD` for a session), so a record's provenance cannot be dressed up as a deliverability fact.\n\nRequires the `suppression:write` scope — Add and remove addresses on your suppression list.",
+ "operationId": "v1CreateSuppression",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SuppressionV1Create"
+ }
}
},
- {
- "description": "End of the window (ISO 8601). Defaults to now.",
- "in": "query",
- "name": "to",
- "required": false,
- "schema": {
- "description": "End of the window (ISO 8601). Defaults to now.",
- "format": "date-time",
- "type": [
- "string",
- "null"
- ]
- }
- }
- ],
+ "required": true
+ },
"responses": {
- "200": {
+ "201": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/AnalyticsCampaignStatsV1"
+ "$ref": "#/components/schemas/SuppressionV1"
}
}
},
- "description": "Campaign statistics"
+ "description": "The suppressed address"
},
"401": {
"content": {
@@ -9880,47 +20491,30 @@
},
{
"OAuth2": [
- "analytics:read"
+ "suppression:write"
]
}
],
- "summary": "Retrieve campaign totals and engagement",
+ "summary": "Suppress an address",
"tags": [
- "Analytics"
+ "Suppression"
]
}
},
- "/api/v1/analytics/timeseries": {
- "get": {
- "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.",
- "operationId": "v1GetAnalyticsTimeseries",
+ "/api/v1/suppressions/{email}": {
+ "delete": {
+ "description": "Clear Sendly's suppression record for this address, so the send pipeline stops refusing it. This is the one operation on this surface that can put mail back into an inbox that asked you to stop, which is why `suppression:write` is a sensitive scope.\n\nIt does NOT remove the address from AWS SES's own account-level suppression list. SES maintains that list independently of anything Sendly stores, so an address SES suppressed after a hard bounce stays undeliverable through SES even once this record is gone — removing it here is not a promise that the next send arrives.\n\nIdempotent: an address that was never suppressed answers `200` too, because \"not on the list\" is the state you asked for.\n\nRequires the `suppression:write` scope — Add and remove addresses on your suppression list.",
+ "operationId": "v1DeleteSuppression",
"parameters": [
{
- "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
- "in": "query",
- "name": "from",
- "required": false,
- "schema": {
- "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
- "format": "date-time",
- "type": [
- "string",
- "null"
- ]
- }
- },
- {
- "description": "End of the window (ISO 8601). Defaults to now.",
- "in": "query",
- "name": "to",
- "required": false,
+ "description": "The suppressed address, URL-encoded.",
+ "in": "path",
+ "name": "email",
+ "required": true,
"schema": {
- "description": "End of the window (ISO 8601). Defaults to now.",
- "format": "date-time",
- "type": [
- "string",
- "null"
- ]
+ "description": "The suppressed address, URL-encoded.",
+ "format": "email",
+ "type": "string"
}
}
],
@@ -9929,11 +20523,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/AnalyticsTimeseriesV1"
+ "$ref": "#/components/schemas/SuppressionV1Deleted"
}
}
},
- "description": "Daily time series"
+ "description": "Address removed from the suppression list"
},
"401": {
"content": {
@@ -9995,58 +20589,28 @@
},
{
"OAuth2": [
- "analytics:read"
+ "suppression:write"
]
}
],
- "summary": "Retrieve the daily email time series",
+ "summary": "Remove an address from the suppression list",
"tags": [
- "Analytics"
+ "Suppression"
]
- }
- },
- "/api/v1/analytics/top-campaigns": {
+ },
"get": {
- "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.",
- "operationId": "v1ListTopCampaigns",
+ "description": "Fetch the suppression record for one address. The path parameter is the address itself, URL-encoded.\n\nAn address that is NOT suppressed answers `404 resource_not_found` — this path addresses the suppression record, and there is none. The answer is definite either way: `200` means suppressed and says why, `404` means not suppressed.\n\nA `200` may also come from a platform-wide block recorded outside this project, in which case the address is genuinely undeliverable for you even though you never suppressed it.\n\nRequires the `suppression:read` scope — View the addresses on your suppression list.",
+ "operationId": "v1GetSuppression",
"parameters": [
{
- "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
- "in": "query",
- "name": "from",
- "required": false,
- "schema": {
- "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
- "format": "date-time",
- "type": [
- "string",
- "null"
- ]
- }
- },
- {
- "description": "End of the window (ISO 8601). Defaults to now.",
- "in": "query",
- "name": "to",
- "required": false,
- "schema": {
- "description": "End of the window (ISO 8601). Defaults to now.",
- "format": "date-time",
- "type": [
- "string",
- "null"
- ]
- }
- },
- {
- "in": "query",
- "name": "limit",
- "required": false,
+ "description": "The suppressed address, URL-encoded.",
+ "in": "path",
+ "name": "email",
+ "required": true,
"schema": {
- "default": 10,
- "maximum": 50,
- "minimum": 1,
- "type": "integer"
+ "description": "The suppressed address, URL-encoded.",
+ "format": "email",
+ "type": "string"
}
}
],
@@ -10055,11 +20619,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/AnalyticsTopCampaignsV1"
+ "$ref": "#/components/schemas/SuppressionV1"
}
}
},
- "description": "Ranked campaigns"
+ "description": "The suppression record"
},
"401": {
"content": {
@@ -10081,6 +20645,16 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`resource_not_found` — this address is not suppressed for the authenticated project."
+ },
"422": {
"content": {
"application/problem+json": {
@@ -10121,20 +20695,20 @@
},
{
"OAuth2": [
- "analytics:read"
+ "suppression:read"
]
}
],
- "summary": "List the best-performing campaigns",
+ "summary": "Check whether an address is suppressed",
"tags": [
- "Analytics"
+ "Suppression"
]
}
},
- "/api/v1/campaigns": {
+ "/api/v1/templates": {
"get": {
- "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\nUnlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.",
- "operationId": "v1ListCampaigns",
+ "description": "Cursor-paginated list of templates, 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`search` is a case-insensitive substring match on the NAME only — narrower than the dashboard's search, which also reads description and subject, because a caller that asked for a name match should not be handed rows that merely mention the word in their body.\n\nA cursor is bound to the filters that minted it. Changing `search` or `email_category` while reusing a cursor answers `422 validation_error` rather than returning a page that belongs to neither query — drop the cursor and start again from the first page.\n\nRequires the `templates:read` scope — View your email templates.",
+ "operationId": "v1ListTemplates",
"parameters": [
{
"in": "query",
@@ -10157,6 +20731,30 @@
"minLength": 1,
"type": "string"
}
+ },
+ {
+ "description": "Case-insensitive substring match on the name.",
+ "in": "query",
+ "name": "search",
+ "required": false,
+ "schema": {
+ "description": "Case-insensitive substring match on the name.",
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "email_category",
+ "required": false,
+ "schema": {
+ "enum": [
+ "TRANSACTIONAL",
+ "MARKETING",
+ "SELF_MANAGED_UNSUBSCRIBE"
+ ],
+ "type": "string"
+ }
}
],
"responses": {
@@ -10164,11 +20762,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1List"
+ "$ref": "#/components/schemas/TemplateV1List"
}
}
},
- "description": "Campaign list"
+ "description": "Template list"
},
"401": {
"content": {
@@ -10230,37 +20828,23 @@
},
{
"OAuth2": [
- "campaigns:read"
- ]
- }
- ],
- "summary": "List campaigns",
- "tags": [
- "Campaigns"
- ]
- },
- "post": {
- "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, and organize your campaigns.",
- "operationId": "v1CreateCampaign",
- "parameters": [
- {
- "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
- "in": "header",
- "name": "Idempotency-Key",
- "required": false,
- "schema": {
- "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
- "maxLength": 255,
- "minLength": 1,
- "type": "string"
- }
+ "templates:read"
+ ]
}
],
+ "summary": "List templates",
+ "tags": [
+ "Templates"
+ ]
+ },
+ "post": {
+ "description": "Create a template. The `from` domain must already be a verified sending identity for this project — an unverified sender answers `403 forbidden` here rather than becoming a campaign that fails at send time.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
+ "operationId": "v1CreateTemplate",
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1Create"
+ "$ref": "#/components/schemas/TemplateV1Create"
}
}
},
@@ -10271,11 +20855,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1"
+ "$ref": "#/components/schemas/TemplateV1"
}
}
},
- "description": "Campaign created"
+ "description": "The created template"
},
"401": {
"content": {
@@ -10297,26 +20881,6 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`resource_not_found` — `segment_id` names a segment that does not belong to this project."
- },
- "409": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly."
- },
"422": {
"content": {
"application/problem+json": {
@@ -10325,7 +20889,7 @@
}
}
},
- "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."
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
@@ -10357,20 +20921,20 @@
},
{
"OAuth2": [
- "campaigns:write"
+ "templates:write"
]
}
],
- "summary": "Create a campaign",
+ "summary": "Create a template",
"tags": [
- "Campaigns"
+ "Templates"
]
}
},
- "/api/v1/campaigns/{id}": {
+ "/api/v1/templates/{id}": {
"delete": {
- "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, and organize your campaigns.",
- "operationId": "v1DeleteCampaign",
+ "description": "Delete the template. Answers `409 conflict` while a workflow step or an active campaign (DRAFT, SCHEDULED or SENDING) still points at it — removing it would leave those referring to content that no longer exists, and the failure would surface at send time instead of here. The emails already sent from this template are NOT erased.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
+ "operationId": "v1DeleteTemplate",
"parameters": [
{
"description": "Resource id.",
@@ -10389,13 +20953,13 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1Deleted"
+ "$ref": "#/components/schemas/TemplateV1Deleted"
}
}
},
- "description": "Campaign deleted"
+ "description": "Template deleted"
},
- "400": {
+ "401": {
"content": {
"application/problem+json": {
"schema": {
@@ -10403,9 +20967,9 @@
}
}
},
- "description": "`validation_error` — only `DRAFT` campaigns can be deleted."
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
"application/problem+json": {
"schema": {
@@ -10413,9 +20977,9 @@
}
}
},
- "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
"application/problem+json": {
"schema": {
@@ -10423,9 +20987,9 @@
}
}
},
- "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
+ "description": "`resource_not_found` — no template with this id belongs to the authenticated project."
},
- "404": {
+ "409": {
"content": {
"application/problem+json": {
"schema": {
@@ -10433,7 +20997,7 @@
}
}
},
- "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
+ "description": "`conflict` — the template is still referenced by a workflow step or an active campaign."
},
"422": {
"content": {
@@ -10475,18 +21039,18 @@
},
{
"OAuth2": [
- "campaigns:write"
+ "templates:write"
]
}
],
- "summary": "Delete a campaign",
+ "summary": "Delete a template",
"tags": [
- "Campaigns"
+ "Templates"
]
},
"get": {
- "description": "Fetch one campaign, including its materialized delivery counters.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.",
- "operationId": "v1GetCampaign",
+ "description": "Fetch one template by id.\n\nRequires the `templates:read` scope — View your email templates.",
+ "operationId": "v1GetTemplate",
"parameters": [
{
"description": "Resource id.",
@@ -10505,11 +21069,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1"
+ "$ref": "#/components/schemas/TemplateV1"
}
}
},
- "description": "The campaign"
+ "description": "The template"
},
"401": {
"content": {
@@ -10539,7 +21103,7 @@
}
}
},
- "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
+ "description": "`resource_not_found` — no template with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -10581,18 +21145,18 @@
},
{
"OAuth2": [
- "campaigns:read"
+ "templates:read"
]
}
],
- "summary": "Retrieve a campaign",
+ "summary": "Retrieve a template",
"tags": [
- "Campaigns"
+ "Templates"
]
},
"patch": {
- "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, and organize your campaigns.",
- "operationId": "v1UpdateCampaign",
+ "description": "Partial update. Omitted fields are left alone.\n\nChanging `subject`, `body`, `from`, `from_name` or `reply_to` snapshots the previous content into the template's version history and increments `version`; changing only `name`, `description` or `email_category` does not, because neither is content a send would have rendered.\n\nA `from` supplied here is verified before anything is written, on the same terms as create.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.",
+ "operationId": "v1UpdateTemplate",
"parameters": [
{
"description": "Resource id.",
@@ -10610,7 +21174,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1Update"
+ "$ref": "#/components/schemas/TemplateV1Update"
}
}
},
@@ -10621,21 +21185,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1"
- }
- }
- },
- "description": "The updated campaign"
- },
- "400": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
+ "$ref": "#/components/schemas/TemplateV1"
}
}
},
- "description": "`validation_error` — the campaign is not in an editable status, or the segment change is not allowed."
+ "description": "The updated template"
},
"401": {
"content": {
@@ -10665,7 +21219,7 @@
}
}
},
- "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
+ "description": "`resource_not_found` — no template with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -10707,31 +21261,53 @@
},
{
"OAuth2": [
- "campaigns:write"
+ "templates:write"
]
}
],
- "summary": "Update a campaign",
+ "summary": "Update a template",
"tags": [
- "Campaigns"
+ "Templates"
]
}
},
- "/api/v1/campaigns/{id}/cancel": {
- "post": {
- "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, and organize your campaigns.",
- "operationId": "v1CancelCampaign",
+ "/api/v1/topics": {
+ "get": {
+ "description": "The subjects this project mails about, cursor-paginated and newest first.\n\nArchived topics are omitted unless you ask for them with `include_archived`. Archiving is the retire button and there is no delete: a topic is where people's answers are recorded, so removing it would remove the choices they made.\n\n`subscribed_count` counts contacts who explicitly said yes. It reads low on a topic with `default_opt_in` true, and that is the honest number — it is how many people answered, not how many would currently receive the mail.\n\nRequires the `topics:read` scope — View the topics you mail about and who is subscribed to each.",
+ "operationId": "v1ListTopics",
"parameters": [
{
- "description": "Resource id.",
- "in": "path",
- "name": "id",
- "required": true,
+ "in": "query",
+ "name": "limit",
+ "required": false,
"schema": {
- "description": "Resource id.",
- "format": "uuid",
+ "default": 50,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
"type": "string"
}
+ },
+ {
+ "in": "query",
+ "name": "include_archived",
+ "required": false,
+ "schema": {
+ "type": [
+ "boolean",
+ "null"
+ ]
+ }
}
],
"responses": {
@@ -10739,21 +21315,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1"
- }
- }
- },
- "description": "The cancelled campaign"
- },
- "400": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
+ "$ref": "#/components/schemas/TopicListV1"
}
}
},
- "description": "`validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled."
+ "description": "One page of topics"
},
"401": {
"content": {
@@ -10775,16 +21341,6 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
- },
"422": {
"content": {
"application/problem+json": {
@@ -10825,53 +21381,38 @@
},
{
"OAuth2": [
- "campaigns:write"
+ "topics:read"
]
}
],
- "summary": "Cancel a campaign",
+ "summary": "List topics",
"tags": [
- "Campaigns"
+ "Topics"
]
- }
- },
- "/api/v1/campaigns/{id}/pause": {
+ },
"post": {
- "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, and organize your campaigns.",
- "operationId": "v1PauseCampaign",
- "parameters": [
- {
- "description": "Resource id.",
- "in": "path",
- "name": "id",
- "required": true,
- "schema": {
- "description": "Resource id.",
- "format": "uuid",
- "type": "string"
+ "description": "`key` is the stable, project-unique name every preference form and integration refers to, so it survives a rename of `name` and cannot be edited afterwards.\n\n`default_opt_in` is the field worth thinking about: it decides what SILENCE means for every contact who never answers. Leave it true for a topic introduced over a list you already have — those contacts consented to hear from you, and inventing an opt-out they never asked for would mute mail they expect. Set it false for anything a person has to ask for, and absence then means `not asked` rather than `no`.\n\nRequires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach.",
+ "operationId": "v1CreateTopic",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TopicCreateV1"
+ }
}
- }
- ],
+ },
+ "required": true
+ },
"responses": {
- "200": {
+ "201": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1"
- }
- }
- },
- "description": "The paused campaign"
- },
- "400": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
+ "$ref": "#/components/schemas/TopicV1"
}
}
},
- "description": "`validation_error` — only a `SENDING` campaign can be paused."
+ "description": "The created topic"
},
"401": {
"content": {
@@ -10893,16 +21434,6 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
- },
"422": {
"content": {
"application/problem+json": {
@@ -10943,20 +21474,20 @@
},
{
"OAuth2": [
- "campaigns:write"
+ "topics:write"
]
}
],
- "summary": "Pause a sending campaign",
+ "summary": "Create a topic",
"tags": [
- "Campaigns"
+ "Topics"
]
}
},
- "/api/v1/campaigns/{id}/resume": {
- "post": {
- "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, and organize your campaigns.",
- "operationId": "v1ResumeCampaign",
+ "/api/v1/topics/{id}": {
+ "get": {
+ "description": "Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each.",
+ "operationId": "v1GetTopic",
"parameters": [
{
"description": "Resource id.",
@@ -10975,21 +21506,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1"
- }
- }
- },
- "description": "The resumed campaign"
- },
- "400": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
+ "$ref": "#/components/schemas/TopicV1"
}
}
},
- "description": "`validation_error` — only a `PAUSED` campaign can be resumed."
+ "description": "The topic"
},
"401": {
"content": {
@@ -11019,7 +21540,7 @@
}
}
},
- "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
+ "description": "`resource_not_found` — no topic with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -11061,20 +21582,18 @@
},
{
"OAuth2": [
- "campaigns:write"
+ "topics:read"
]
}
],
- "summary": "Resume a paused campaign",
+ "summary": "Retrieve a topic",
"tags": [
- "Campaigns"
+ "Topics"
]
- }
- },
- "/api/v1/campaigns/{id}/send": {
- "post": {
- "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:send` scope — Send or schedule your campaigns to their audience.",
- "operationId": "v1SendCampaign",
+ },
+ "patch": {
+ "description": "Rename it, re-describe it, flip `default_opt_in`, or archive it.\n\n`key` is absent from the body on purpose. Every stored preference and every integration refers to a topic by its key, so changing one would orphan them silently. There is no DELETE for the same reason — `archived: true` removes the topic from the preference centre and from new sends while every opt-out recorded against it survives.\n\nRequires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach.",
+ "operationId": "v1UpdateTopic",
"parameters": [
{
"description": "Resource id.",
@@ -11086,50 +21605,28 @@
"format": "uuid",
"type": "string"
}
- },
- {
- "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
- "in": "header",
- "name": "Idempotency-Key",
- "required": false,
- "schema": {
- "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
- "maxLength": 255,
- "minLength": 1,
- "type": "string"
- }
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1Send"
+ "$ref": "#/components/schemas/TopicUpdateV1"
}
}
},
- "required": false
+ "required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1"
- }
- }
- },
- "description": "The campaign, now `SENDING` or `SCHEDULED`"
- },
- "400": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
+ "$ref": "#/components/schemas/TopicV1"
}
}
},
- "description": "`validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future."
+ "description": "The updated topic"
},
"401": {
"content": {
@@ -11159,17 +21656,7 @@
}
}
},
- "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
- },
- "409": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly."
+ "description": "`resource_not_found` — no topic with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -11179,7 +21666,7 @@
}
}
},
- "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."
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
@@ -11211,20 +21698,20 @@
},
{
"OAuth2": [
- "campaigns:send"
+ "topics:write"
]
}
],
- "summary": "Send or schedule a campaign",
+ "summary": "Update a topic",
"tags": [
- "Campaigns"
+ "Topics"
]
}
},
- "/api/v1/campaigns/{id}/stats": {
- "get": {
- "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.",
- "operationId": "v1GetCampaignStats",
+ "/api/v1/topics/{id}/subscriptions": {
+ "post": {
+ "description": "The two directions behave differently, and the asymmetry is deliberate: consent needs proof, withdrawal of consent does not.\n\n`subscribed: true` does NOT subscribe anyone. It parks the contact at `pending` and returns a `confirmation_url`; nothing is mailed on this topic until somebody opens it. There is no parameter to skip that step. A caller asserting a subscription is not evidence that the mailbox holder agreed — anyone can type an address into a form — and treating the assertion as consent is the exact failure double opt-in exists to prevent. Sendly does not send the confirmation email; you do, from your own verified domain, because it is your relationship with the contact and your sending reputation.\n\n`subscribed: false` records the opt-out immediately. Requiring somebody to confirm that they want to stop is a dark pattern, and it is also the fastest route to a spam report.\n\nRequires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach.",
+ "operationId": "v1SetTopicSubscription",
"parameters": [
{
"description": "Resource id.",
@@ -11238,16 +21725,26 @@
}
}
],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TopicSubscribeV1"
+ }
+ }
+ },
+ "required": true
+ },
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CampaignV1Stats"
+ "$ref": "#/components/schemas/TopicSubscriptionV1"
}
}
},
- "description": "Campaign statistics"
+ "description": "The resulting subscription"
},
"401": {
"content": {
@@ -11277,7 +21774,7 @@
}
}
},
- "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project."
+ "description": "`resource_not_found` — no topic with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -11319,54 +21816,30 @@
},
{
"OAuth2": [
- "campaigns:read"
+ "topics:write"
]
}
],
- "summary": "Retrieve campaign statistics",
+ "summary": "Subscribe or unsubscribe a contact from a topic",
"tags": [
- "Campaigns"
+ "Topics"
]
}
},
- "/api/v1/emails": {
- "post": {
- "description": "Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`.\n\nThis is the send to reach for when you need to know what happened. The legacy `POST /api/emails` answers with row ids and no status, so telling an accepted send from a refused one costs a second request; here the receipt carries `status`, and `from` reports the sender actually used — which is worth reading, since a template may have supplied it.\n\nExactly ONE recipient. A single receipt cannot describe a fan-out, so `to` takes one address: use `cc`/`bcc` to copy others on the same message, and `POST /api/emails/batch` to send different ones.\n\n`202 Accepted` is the success answer, and `PENDING` the usual `status`: the message is queued for the sending pipeline, not yet handed to the provider. Later states (`DELIVERED`, `BOUNCED`, …) arrive by webhook.\n\nAn optional `Idempotency-Key` header (1–255 chars, 24h TTL) makes a retry safe: the first request wins and a retry carrying the same key AND body replays its receipt. Reusing a key with a different body answers 422 rather than serving another request's result.\n\nRequires the `emails:send` scope — Send emails from your verified domains.",
- "operationId": "v1SendEmail",
- "parameters": [
- {
- "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
- "in": "header",
- "name": "Idempotency-Key",
- "required": false,
- "schema": {
- "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.",
- "maxLength": 255,
- "minLength": 1,
- "type": "string"
- }
- }
- ],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/SendEmailV1"
- }
- }
- },
- "required": true
- },
+ "/api/v1/usage": {
+ "get": {
+ "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.",
+ "operationId": "v1GetUsage",
"responses": {
- "202": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/EmailV1"
+ "$ref": "#/components/schemas/UsageV1"
}
}
},
- "description": "Email queued"
+ "description": "Current usage"
},
"401": {
"content": {
@@ -11386,27 +21859,7 @@
}
}
},
- "description": "`scope_missing`, `project_disabled`, `domain_not_allowed` — the `from` address does not resolve to a verified domain on this project — or `content_rejected`: automated content review flagged the message and it was not sent."
- },
- "404": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`resource_not_found` — `template` names a template that does not belong to this project."
- },
- "409": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly."
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
"422": {
"content": {
@@ -11416,7 +21869,7 @@
}
}
},
- "description": "`validation_error` — the body did not match the schema; or `idempotency_key_reused` — this `Idempotency-Key` was already spent on a request with a different body. Send a new key."
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
@@ -11437,16 +21890,6 @@
}
},
"description": "`internal_error`."
- },
- "503": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "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."
}
},
"security": [
@@ -11458,40 +21901,43 @@
},
{
"OAuth2": [
- "emails:send"
+ "usage:read"
]
}
],
- "summary": "Send a transactional email",
+ "summary": "Retrieve current usage and limits",
"tags": [
- "Emails"
+ "Usage"
]
}
},
- "/api/v1/emails/test": {
- "post": {
- "description": "Prove that sending works — before any domain, DNS record or verification exists.\n\nThe message is sent FROM this project's sandbox address (`sandbox_address` on `GET /api/v1/projects`) and can only reach ONE recipient: the project owner's own verified account email, which is also what `to` defaults to. Naming any other recipient answers 403, and naming a `from` answers 422 — the sender is resolved server-side and a request that expects a different one is refused rather than quietly re-addressed.\n\nThat restriction is the reason `emails:test` is a separate, non-sensitive scope: a call under it cannot put mail in a stranger's inbox, so it can sit in a default grant where `emails:send` may not. It is not a way to send real mail cheaply — use `POST /api/v1/emails` for that.\n\nSandbox sends are capped per project per day, and the response's `sandbox: true` marks the receipt so a relayed summary cannot pass a test send off as a real one.\n\nRequires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox.",
- "operationId": "v1SendTestEmail",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/SendTestEmailV1"
- }
+ "/api/v1/validation-runs/{id}": {
+ "get": {
+ "description": "How far a run has got and what it found. The counters are advanced by each batch in the same step that commits the rows they count, so they are a ledger rather than a cached estimate and may be branched on.\n\nThere is no total to divide by, deliberately: a list changes size while a run walks it, so a denominator captured when the run started would be wrong by the time you read it. A run is finished when `status` is `completed` or `failed`, never when a percentage reaches 100.\n\nRequires the `validation:read` scope — View your email validation runs and their results.",
+ "operationId": "v1GetValidationRun",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
}
- },
- "required": true
- },
+ }
+ ],
"responses": {
- "202": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/EmailTestV1"
+ "$ref": "#/components/schemas/EmailValidationRunV1"
}
}
},
- "description": "Test email queued"
+ "description": "The run"
},
"401": {
"content": {
@@ -11511,9 +21957,9 @@
}
}
},
- "description": "`scope_missing`, `project_disabled`, or `forbidden` — the recipient is not the project owner's own verified account email (including the case where that address is not verified yet, so there is no default recipient), or `content_rejected` from automated content review."
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "409": {
+ "404": {
"content": {
"application/problem+json": {
"schema": {
@@ -11521,7 +21967,7 @@
}
}
},
- "description": "`conflict` — this project has no sandbox sender. The handle is derived from the project owner's email and this project has no owner; no retry fixes it."
+ "description": "`resource_not_found` — no validation run with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -11531,7 +21977,7 @@
}
}
},
- "description": "`validation_error` — the body did not match the schema, most often because it named a `from`. A test send always comes from the sandbox address."
+ "description": "`validation_error` — query, path, or body parameters did not match the schema."
},
"429": {
"content": {
@@ -11541,7 +21987,7 @@
}
}
},
- "description": "`rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC."
+ "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers."
},
"500": {
"content": {
@@ -11552,16 +21998,6 @@
}
},
"description": "`internal_error`."
- },
- "503": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`content_review_unavailable` — content review could not run for this new account. Safe to retry."
}
},
"security": [
@@ -11573,28 +22009,39 @@
},
{
"OAuth2": [
- "emails:test"
+ "validation:read"
]
}
],
- "summary": "Send a sandbox test email",
+ "summary": "Retrieve a validation run",
"tags": [
- "Emails"
+ "Validation"
]
}
},
- "/api/v1/events": {
+ "/api/v1/validation-runs/{id}/results": {
"get": {
- "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.",
- "operationId": "v1ListEvents",
+ "description": "One page of a run's verdicts, cursor-paginated. Filter with `verdict` — `undeliverable` is the page you want when you are about to act on the results.\n\nNo total is reported: a run over a large list holds millions of rows, and counting them on every page read is the query this design exists to avoid. The run's own counters carry the numbers worth having.\n\n`contact_id` is the contact the address belonged to when it was checked, and it is null for a contact deleted since. The `email` is stored on the result rather than read through the contact, so a completed run reports what it actually checked even after the contact changed address.\n\nRequires the `validation:read` scope — View your email validation runs and their results.",
+ "operationId": "v1ListValidationRunResults",
"parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
+ }
+ },
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
- "default": 20,
- "maximum": 100,
+ "default": 50,
+ "maximum": 200,
"minimum": 1,
"type": "integer"
}
@@ -11611,15 +22058,19 @@
}
},
{
- "description": "Return only events with this exact name.",
+ "description": "Return only results with this verdict — `undeliverable` is the usual filter.",
"in": "query",
- "name": "event_name",
+ "name": "verdict",
"required": false,
"schema": {
- "description": "Return only events with this exact name.",
- "maxLength": 200,
- "minLength": 1,
- "type": "string"
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/EmailValidationVerdictV1"
+ },
+ {
+ "description": "Return only results with this verdict — `undeliverable` is the usual filter."
+ }
+ ]
}
}
],
@@ -11628,11 +22079,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/EventV1List"
+ "$ref": "#/components/schemas/EmailValidationResultListV1"
}
}
},
- "description": "Event list"
+ "description": "One page of results"
},
"401": {
"content": {
@@ -11654,6 +22105,16 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`resource_not_found` — no validation run with this id belongs to the authenticated project."
+ },
"422": {
"content": {
"application/problem+json": {
@@ -11694,38 +22155,54 @@
},
{
"OAuth2": [
- "events:read"
+ "validation:read"
]
}
],
- "summary": "List events",
+ "summary": "List a validation run's results",
"tags": [
- "Events"
+ "Validation"
]
- },
- "post": {
- "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\nSending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else.\n\nRequires the `events:write` scope — Record custom events for your contacts.",
- "operationId": "v1TrackEvent",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/EventTrackV1"
- }
+ }
+ },
+ "/api/v1/webhooks": {
+ "get": {
+ "description": "Cursor-paginated list of webhook endpoints, 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\nSigning secrets are not on this response and cannot be read back — see `POST /api/v1/webhooks/{id}/rotate-secret` if you have lost one.\n\nRequires the `webhooks:read` scope — View your webhook endpoints and their delivery history.",
+ "operationId": "v1ListWebhooks",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
}
},
- "required": true
- },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
+ "type": "string"
+ }
+ }
+ ],
"responses": {
- "201": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/EventV1"
+ "$ref": "#/components/schemas/WebhookV1List"
}
}
},
- "description": "Event recorded"
+ "description": "Webhook list"
},
"401": {
"content": {
@@ -11747,16 +22224,6 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`resource_not_found` — no contact with this id in the authenticated project."
- },
"422": {
"content": {
"application/problem+json": {
@@ -11797,30 +22264,38 @@
},
{
"OAuth2": [
- "events:write"
+ "webhooks:read"
]
}
],
- "summary": "Record an event",
+ "summary": "List webhooks",
"tags": [
- "Events"
+ "Webhooks"
]
- }
- },
- "/api/v1/events/names": {
- "get": {
- "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.",
- "operationId": "v1ListEventNames",
+ },
+ "post": {
+ "description": "Register an endpoint to receive HMAC-signed deliveries for the events named in `event_types`.\n\nThe response carries the signing secret ONCE. It is shown here and by `POST /api/v1/webhooks/{id}/rotate-secret`, and by nothing else — no endpoint reads it back, so store it now. A secret you have lost is replaced by rotating, not recovered.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.",
+ "operationId": "v1CreateWebhook",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookV1Create"
+ }
+ }
+ },
+ "required": true
+ },
"responses": {
- "200": {
+ "201": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/EventNamesV1"
+ "$ref": "#/components/schemas/WebhookV1Created"
}
}
},
- "description": "Event names"
+ "description": "The created webhook and its one-time signing secret"
},
"401": {
"content": {
@@ -11882,47 +22357,30 @@
},
{
"OAuth2": [
- "events:read"
+ "webhooks:write"
]
}
],
- "summary": "List event names",
+ "summary": "Create a webhook",
"tags": [
- "Events"
+ "Webhooks"
]
}
},
- "/api/v1/events/stats": {
- "get": {
- "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.",
- "operationId": "v1GetEventStats",
+ "/api/v1/webhooks/{id}": {
+ "delete": {
+ "description": "Remove the endpoint and its delivery history — a delivery attempt is a fact about this endpoint and has no meaning once the endpoint is gone. Deliveries already in flight are not recalled, so the endpoint may still receive an event shortly after this returns.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.",
+ "operationId": "v1DeleteWebhook",
"parameters": [
{
- "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
- "in": "query",
- "name": "from",
- "required": false,
- "schema": {
- "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.",
- "format": "date-time",
- "type": [
- "string",
- "null"
- ]
- }
- },
- {
- "description": "End of the window (ISO 8601). Defaults to now.",
- "in": "query",
- "name": "to",
- "required": false,
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
"schema": {
- "description": "End of the window (ISO 8601). Defaults to now.",
- "format": "date-time",
- "type": [
- "string",
- "null"
- ]
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
}
}
],
@@ -11931,11 +22389,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/EventStatsV1"
+ "$ref": "#/components/schemas/WebhookV1Deleted"
}
}
},
- "description": "Event counts"
+ "description": "Webhook deleted"
},
"401": {
"content": {
@@ -11957,6 +22415,16 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project."
+ },
"422": {
"content": {
"application/problem+json": {
@@ -11997,30 +22465,41 @@
},
{
"OAuth2": [
- "events:read"
+ "webhooks:write"
]
}
],
- "summary": "Retrieve event counts",
+ "summary": "Delete a webhook",
"tags": [
- "Events"
+ "Webhooks"
]
- }
- },
- "/api/v1/projects": {
+ },
"get": {
- "description": "The project the presented credential is scoped to — resolved from the API key, or from the `x-project-id` header for a session or delegated token. Singular despite the plural path, like `/api/v1/usage`: every v1 operation acts on exactly one project.\n\n`sandbox_address` is this project's quick-start sender. It works with no domain setup, but only to the project owner's own verified address and under a daily cap — it is how you prove sending works end to end before any DNS exists. It is null when no handle can be derived (a project with no owner).\n\nTo enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`.\n\nRequires the `projects:read` scope — View your projects and their settings.",
- "operationId": "v1GetProject",
+ "description": "Fetch one webhook endpoint by id. The signing secret is not part of this response.\n\nRequires the `webhooks:read` scope — View your webhook endpoints and their delivery history.",
+ "operationId": "v1GetWebhook",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ProjectV1"
+ "$ref": "#/components/schemas/WebhookV1"
}
}
},
- "description": "The authenticated project"
+ "description": "The webhook"
},
"401": {
"content": {
@@ -12050,7 +22529,7 @@
}
}
},
- "description": "`resource_not_found` — the project was deleted between authentication and this read."
+ "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -12092,54 +22571,51 @@
},
{
"OAuth2": [
- "projects:read"
+ "webhooks:read"
]
}
],
- "summary": "Retrieve the authenticated project",
+ "summary": "Retrieve a webhook",
"tags": [
- "Projects"
+ "Webhooks"
]
- }
- },
- "/api/v1/segments": {
- "get": {
- "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.",
- "operationId": "v1ListSegments",
+ },
+ "patch": {
+ "description": "Partial update. Omitted fields are left alone.\n\n`event_types` REPLACES the stored subscription list rather than merging into it, so an event you omit is unsubscribed. Setting `status` back to `ACTIVE` from `DISABLED` also clears the consecutive-failure counter, so an auto-disabled endpoint gets a clean slate.\n\nThe signing secret is untouched by an update, and is not part of this response.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.",
+ "operationId": "v1UpdateWebhook",
"parameters": [
{
- "in": "query",
- "name": "limit",
- "required": false,
- "schema": {
- "default": 20,
- "maximum": 100,
- "minimum": 1,
- "type": "integer"
- }
- },
- {
- "description": "Opaque cursor from a previous response's `next_cursor`.",
- "in": "query",
- "name": "after",
- "required": false,
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
"schema": {
- "description": "Opaque cursor from a previous response's `next_cursor`.",
- "minLength": 1,
+ "description": "Resource id.",
+ "format": "uuid",
"type": "string"
}
}
],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookV1Update"
+ }
+ }
+ },
+ "required": true
+ },
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SegmentV1List"
+ "$ref": "#/components/schemas/WebhookV1"
}
}
},
- "description": "Segment list"
+ "description": "The updated webhook"
},
"401": {
"content": {
@@ -12161,6 +22637,16 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project."
+ },
"422": {
"content": {
"application/problem+json": {
@@ -12201,40 +22687,45 @@
},
{
"OAuth2": [
- "segments:read"
+ "webhooks:write"
]
}
],
- "summary": "List segments",
+ "summary": "Update a webhook",
"tags": [
- "Segments"
+ "Webhooks"
]
- },
+ }
+ },
+ "/api/v1/webhooks/{id}/rotate-secret": {
"post": {
- "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.",
- "operationId": "v1CreateSegment",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/SegmentV1Create"
- }
+ "description": "Mint a fresh signing secret. The new plaintext is returned EXACTLY ONCE, here — no endpoint reads it back, so a secret you lose is replaced by rotating again rather than recovered.\n\nRotation overlaps on purpose. The outgoing secret keeps verifying until `previous_secret_expires_at`, and every delivery inside that window carries BOTH signatures in the `webhook-signature` header — so you can deploy the new secret to your verifier whenever you like without dropping an in-flight event. Rotating twice inside the window discards the older secret: only one previous secret is ever live.\n\n`url`, `event_types` and `status` are unchanged.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.",
+ "operationId": "v1RotateWebhookSecret",
+ "parameters": [
+ {
+ "description": "Resource id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Resource id.",
+ "format": "uuid",
+ "type": "string"
}
- },
- "required": true
- },
+ }
+ ],
"responses": {
- "201": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SegmentV1"
+ "$ref": "#/components/schemas/WebhookV1SecretRotated"
}
}
},
- "description": "Segment created"
+ "description": "The new signing secret and the moment the previous one stops verifying"
},
- "400": {
+ "401": {
"content": {
"application/problem+json": {
"schema": {
@@ -12242,9 +22733,9 @@
}
}
},
- "description": "`validation_error` — a `DYNAMIC` segment was submitted without a `condition`."
+ "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
},
- "401": {
+ "403": {
"content": {
"application/problem+json": {
"schema": {
@@ -12252,9 +22743,9 @@
}
}
},
- "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials."
+ "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "403": {
+ "404": {
"content": {
"application/problem+json": {
"schema": {
@@ -12262,7 +22753,7 @@
}
}
},
- "description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
+ "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -12304,29 +22795,40 @@
},
{
"OAuth2": [
- "segments:write"
+ "webhooks:write"
]
}
],
- "summary": "Create a segment",
+ "summary": "Rotate a webhook signing secret",
"tags": [
- "Segments"
+ "Webhooks"
]
}
},
- "/api/v1/segments/{id}": {
- "delete": {
- "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.",
- "operationId": "v1DeleteSegment",
+ "/api/v1/workflows": {
+ "get": {
+ "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\nUnlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.",
+ "operationId": "v1ListWorkflows",
"parameters": [
{
- "description": "Resource id.",
- "in": "path",
- "name": "id",
- "required": true,
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
"schema": {
- "description": "Resource id.",
- "format": "uuid",
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
"type": "string"
}
}
@@ -12336,11 +22838,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SegmentV1Deleted"
+ "$ref": "#/components/schemas/WorkflowV1List"
}
}
},
- "description": "Segment deleted"
+ "description": "Workflow list"
},
"401": {
"content": {
@@ -12362,26 +22864,6 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`resource_not_found` — no segment with this id belongs to the authenticated project."
- },
- "409": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`conflict` — the segment is still used by one or more active campaigns."
- },
"422": {
"content": {
"application/problem+json": {
@@ -12422,41 +22904,38 @@
},
{
"OAuth2": [
- "segments:write"
+ "workflows:read"
]
}
],
- "summary": "Delete a segment",
+ "summary": "List workflows",
"tags": [
- "Segments"
+ "Workflows"
]
},
- "get": {
- "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.",
- "operationId": "v1GetSegment",
- "parameters": [
- {
- "description": "Resource id.",
- "in": "path",
- "name": "id",
- "required": true,
- "schema": {
- "description": "Resource id.",
- "format": "uuid",
- "type": "string"
+ "post": {
+ "description": "Creates a workflow with its single trigger step. `trigger_type` defaults to `EVENT`, which requires `event_name`; `SCHEDULE` takes `interval_ms` and `MANUAL` is started only by `POST /api/v1/workflows/{id}/executions`.\n\nPass `sequence` to create the steps at the same time, as a LINEAR chain behind the trigger. Anything with a branch is `PUT /api/v1/workflows/{id}/graph`. Without a sequence the workflow holds only its trigger and stays inert until it has steps to run, which is why it is created disabled.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.",
+ "operationId": "v1CreateWorkflow",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkflowCreateV1"
+ }
}
- }
- ],
+ },
+ "required": true
+ },
"responses": {
- "200": {
+ "201": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SegmentV1"
+ "$ref": "#/components/schemas/WorkflowV1"
}
}
},
- "description": "The segment"
+ "description": "Workflow created"
},
"401": {
"content": {
@@ -12478,16 +22957,6 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
- "404": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`resource_not_found` — no segment with this id belongs to the authenticated project."
- },
"422": {
"content": {
"application/problem+json": {
@@ -12528,51 +22997,43 @@
},
{
"OAuth2": [
- "segments:read"
+ "workflows:write"
]
}
],
- "summary": "Retrieve a segment",
+ "summary": "Create a workflow",
"tags": [
- "Segments"
+ "Workflows"
]
- },
- "patch": {
- "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.",
- "operationId": "v1UpdateSegment",
+ }
+ },
+ "/api/v1/workflows/executions/{execution_id}/cancel": {
+ "post": {
+ "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.",
+ "operationId": "v1CancelWorkflowExecution",
"parameters": [
{
- "description": "Resource id.",
+ "description": "Workflow execution id.",
"in": "path",
- "name": "id",
+ "name": "execution_id",
"required": true,
"schema": {
- "description": "Resource id.",
+ "description": "Workflow execution id.",
"format": "uuid",
"type": "string"
}
}
],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/SegmentV1Update"
- }
- }
- },
- "required": true
- },
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SegmentV1"
+ "$ref": "#/components/schemas/WorkflowExecutionV1"
}
}
},
- "description": "The updated segment"
+ "description": "Cancelled execution"
},
"401": {
"content": {
@@ -12602,7 +23063,7 @@
}
}
},
- "description": "`resource_not_found` — no segment with this id belongs to the authenticated project."
+ "description": "`resource_not_found` — no execution with this id in the authenticated project."
},
"422": {
"content": {
@@ -12644,53 +23105,31 @@
},
{
"OAuth2": [
- "segments:write"
+ "workflows:write"
]
}
],
- "summary": "Update a segment",
+ "summary": "Cancel a workflow execution",
"tags": [
- "Segments"
+ "Workflows"
]
}
},
- "/api/v1/segments/{id}/contacts": {
- "get": {
- "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.",
- "operationId": "v1ListSegmentContacts",
+ "/api/v1/workflows/{id}": {
+ "delete": {
+ "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.",
+ "operationId": "v1DeleteWorkflow",
"parameters": [
{
- "description": "Resource id.",
+ "description": "Workflow id.",
"in": "path",
"name": "id",
"required": true,
"schema": {
- "description": "Resource id.",
+ "description": "Workflow id.",
"format": "uuid",
"type": "string"
}
- },
- {
- "in": "query",
- "name": "limit",
- "required": false,
- "schema": {
- "default": 20,
- "maximum": 100,
- "minimum": 1,
- "type": "integer"
- }
- },
- {
- "description": "Opaque cursor from a previous response's `next_cursor`.",
- "in": "query",
- "name": "after",
- "required": false,
- "schema": {
- "description": "Opaque cursor from a previous response's `next_cursor`.",
- "minLength": 1,
- "type": "string"
- }
}
],
"responses": {
@@ -12698,11 +23137,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SegmentContactV1List"
+ "$ref": "#/components/schemas/WorkflowDeletedV1"
}
}
},
- "description": "Segment member list"
+ "description": "Workflow deleted"
},
"401": {
"content": {
@@ -12732,7 +23171,17 @@
}
}
},
- "description": "`resource_not_found` — no segment with this id belongs to the authenticated project."
+ "description": "`resource_not_found` — no workflow with this id belongs to the authenticated project."
+ },
+ "409": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`conflict` — the workflow still has running executions."
},
"422": {
"content": {
@@ -12774,30 +23223,41 @@
},
{
"OAuth2": [
- "segments:read"
+ "workflows:write"
]
}
],
- "summary": "List the contacts in a segment",
+ "summary": "Delete a workflow",
"tags": [
- "Segments"
+ "Workflows"
]
- }
- },
- "/api/v1/usage": {
+ },
"get": {
- "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.",
- "operationId": "v1GetUsage",
+ "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.",
+ "operationId": "v1GetWorkflow",
+ "parameters": [
+ {
+ "description": "Workflow id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Workflow id.",
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UsageV1"
+ "$ref": "#/components/schemas/WorkflowV1"
}
}
},
- "description": "Current usage"
+ "description": "Workflow"
},
"401": {
"content": {
@@ -12819,6 +23279,16 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`resource_not_found` — no workflow with this id belongs to the authenticated project."
+ },
"422": {
"content": {
"application/problem+json": {
@@ -12859,54 +23329,51 @@
},
{
"OAuth2": [
- "usage:read"
+ "workflows:read"
]
}
],
- "summary": "Retrieve current usage and limits",
+ "summary": "Retrieve a workflow",
"tags": [
- "Usage"
+ "Workflows"
]
- }
- },
- "/api/v1/workflows": {
- "get": {
- "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\nUnlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.",
- "operationId": "v1ListWorkflows",
+ },
+ "patch": {
+ "description": "Sparse update — omitted fields are left unchanged.\n\nTwo state rules apply: the trigger (`trigger_type`/`event_name`/`interval_ms`) 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\n`sequence` REPLACES every non-trigger step with a linear chain and is likewise refused while executions are running. Omit it to leave the graph untouched.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.",
+ "operationId": "v1UpdateWorkflow",
"parameters": [
{
- "in": "query",
- "name": "limit",
- "required": false,
- "schema": {
- "default": 20,
- "maximum": 100,
- "minimum": 1,
- "type": "integer"
- }
- },
- {
- "description": "Opaque cursor from a previous response's `next_cursor`.",
- "in": "query",
- "name": "after",
- "required": false,
+ "description": "Workflow id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
"schema": {
- "description": "Opaque cursor from a previous response's `next_cursor`.",
- "minLength": 1,
+ "description": "Workflow id.",
+ "format": "uuid",
"type": "string"
}
}
],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkflowUpdateV1"
+ }
+ }
+ },
+ "required": true
+ },
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/WorkflowV1List"
+ "$ref": "#/components/schemas/WorkflowV1"
}
}
},
- "description": "Workflow list"
+ "description": "Updated workflow"
},
"401": {
"content": {
@@ -12928,6 +23395,26 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`resource_not_found` — no workflow with this id belongs to the authenticated project."
+ },
+ "409": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`conflict` — the trigger cannot be changed while executions are running."
+ },
"422": {
"content": {
"application/problem+json": {
@@ -12968,27 +23455,42 @@
},
{
"OAuth2": [
- "workflows:read"
+ "workflows:write"
]
}
],
- "summary": "List workflows",
+ "summary": "Update a workflow",
"tags": [
"Workflows"
]
- },
+ }
+ },
+ "/api/v1/workflows/{id}/clone": {
"post": {
- "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.",
- "operationId": "v1CreateWorkflow",
+ "description": "Copies a workflow and its whole graph as a new workflow. The copy is always created disabled, whatever the original was: a clone exists to be reviewed, and one that started live would match the same trigger events as its original from the moment it appeared.\n\nServer-side rather than a read-then-write, so the copy is taken from one consistent read of the source.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.",
+ "operationId": "v1CloneWorkflow",
+ "parameters": [
+ {
+ "description": "Workflow id.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Workflow id.",
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/WorkflowCreateV1"
+ "$ref": "#/components/schemas/WorkflowCloneV1"
}
}
},
- "required": true
+ "required": false
},
"responses": {
"201": {
@@ -12999,7 +23501,7 @@
}
}
},
- "description": "Workflow created"
+ "description": "The cloned workflow"
},
"401": {
"content": {
@@ -13021,6 +23523,16 @@
},
"description": "`scope_missing`, `project_access_denied`, or `project_disabled`."
},
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/Problem"
+ }
+ }
+ },
+ "description": "`resource_not_found` — no workflow with this id belongs to the authenticated project."
+ },
"422": {
"content": {
"application/problem+json": {
@@ -13065,27 +23577,67 @@
]
}
],
- "summary": "Create a workflow",
+ "summary": "Clone a workflow",
"tags": [
"Workflows"
]
}
},
- "/api/v1/workflows/executions/{execution_id}/cancel": {
- "post": {
- "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.",
- "operationId": "v1CancelWorkflowExecution",
+ "/api/v1/workflows/{id}/executions": {
+ "get": {
+ "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.",
+ "operationId": "v1ListWorkflowExecutions",
"parameters": [
{
- "description": "Workflow execution id.",
+ "description": "Workflow id.",
"in": "path",
- "name": "execution_id",
+ "name": "id",
"required": true,
"schema": {
- "description": "Workflow execution id.",
+ "description": "Workflow id.",
"format": "uuid",
"type": "string"
}
+ },
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "default": 20,
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "in": "query",
+ "name": "after",
+ "required": false,
+ "schema": {
+ "description": "Opaque cursor from a previous response's `next_cursor`.",
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ {
+ "description": "Return only executions in this state.",
+ "in": "query",
+ "name": "status",
+ "required": false,
+ "schema": {
+ "description": "Return only executions in this state.",
+ "enum": [
+ "RUNNING",
+ "WAITING",
+ "COMPLETED",
+ "EXITED",
+ "FAILED",
+ "CANCELLED"
+ ],
+ "type": "string"
+ }
}
],
"responses": {
@@ -13093,11 +23645,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/WorkflowExecutionV1"
+ "$ref": "#/components/schemas/WorkflowExecutionV1List"
}
}
},
- "description": "Cancelled execution"
+ "description": "Execution list"
},
"401": {
"content": {
@@ -13127,7 +23679,7 @@
}
}
},
- "description": "`resource_not_found` — no execution with this id in the authenticated project."
+ "description": "`resource_not_found` — no workflow with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -13169,20 +23721,18 @@
},
{
"OAuth2": [
- "workflows:write"
+ "workflows:read"
]
}
],
- "summary": "Cancel a workflow execution",
+ "summary": "List a workflow's executions",
"tags": [
"Workflows"
]
- }
- },
- "/api/v1/workflows/{id}": {
- "delete": {
- "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.",
- "operationId": "v1DeleteWorkflow",
+ },
+ "post": {
+ "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.",
+ "operationId": "v1StartWorkflowExecution",
"parameters": [
{
"description": "Workflow id.",
@@ -13196,16 +23746,26 @@
}
}
],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkflowExecutionStartV1"
+ }
+ }
+ },
+ "required": true
+ },
"responses": {
- "200": {
+ "201": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/WorkflowDeletedV1"
+ "$ref": "#/components/schemas/WorkflowExecutionV1"
}
}
},
- "description": "Workflow deleted"
+ "description": "Execution started"
},
"401": {
"content": {
@@ -13235,7 +23795,7 @@
}
}
},
- "description": "`resource_not_found` — no workflow with this id in the authenticated project."
+ "description": "`resource_not_found` — no such workflow, or no such contact in this project."
},
"409": {
"content": {
@@ -13245,7 +23805,7 @@
}
}
},
- "description": "`conflict` — the workflow still has running executions."
+ "description": "`conflict` — the contact already has an execution and re-entry is not allowed."
},
"422": {
"content": {
@@ -13291,14 +23851,16 @@
]
}
],
- "summary": "Delete a workflow",
+ "summary": "Start a workflow for a contact",
"tags": [
"Workflows"
]
- },
+ }
+ },
+ "/api/v1/workflows/{id}/graph": {
"get": {
- "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.",
- "operationId": "v1GetWorkflow",
+ "description": "Every step in the workflow, including its `TRIGGER` entry node, and every directed transition between them. `version` is the workflow's version at the time of the read — a different number on a later read means somebody edited the graph in between.\n\nA step's `config` is returned exactly as stored, camelCase keys and all, rather than projected into the snake_case used everywhere else on this API. That is deliberate: the same document is authored by the visual editor, and renaming its keys on the way out would mean any key this API did not know about was silently dropped on the way back in.\n\nThe response of `GET /api/v1/workflows/{id}/graph` is accepted verbatim by `PUT` on the same path: read a graph, change a step, send it back. Step and transition ids are yours to choose on a write, which is what makes that round trip a no-op rather than a rebuild.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.",
+ "operationId": "v1GetWorkflowGraph",
"parameters": [
{
"description": "Workflow id.",
@@ -13317,11 +23879,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/WorkflowV1"
+ "$ref": "#/components/schemas/WorkflowGraphV1"
}
}
},
- "description": "Workflow"
+ "description": "The workflow's graph"
},
"401": {
"content": {
@@ -13351,7 +23913,7 @@
}
}
},
- "description": "`resource_not_found` — no workflow with this id in the authenticated project."
+ "description": "`resource_not_found` — no workflow with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -13397,14 +23959,14 @@
]
}
],
- "summary": "Retrieve a workflow",
+ "summary": "Retrieve a workflow's step graph",
"tags": [
"Workflows"
]
},
- "patch": {
- "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.",
- "operationId": "v1UpdateWorkflow",
+ "put": {
+ "description": "Replaces the whole graph in one transaction, because a graph is only meaningful as nodes PLUS the edges between them — an edit that could apply half of it would leave steps pointing at steps that no longer exist.\n\nA step whose id you send is kept and updated in place; a fresh uuid creates one; an id you omit deletes that step and its run history. Exactly one step must be a `TRIGGER`, every transition must name steps present in the same document, and a step may not point at itself.\n\nRefused with 409 while the workflow has running executions: those runs are standing on the steps being replaced. Pause the workflow (`POST /api/v1/workflows/{id}/pause`) first.\n\nThe response of `GET /api/v1/workflows/{id}/graph` is accepted verbatim by `PUT` on the same path: read a graph, change a step, send it back. Step and transition ids are yours to choose on a write, which is what makes that round trip a no-op rather than a rebuild.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.",
+ "operationId": "v1ReplaceWorkflowGraph",
"parameters": [
{
"description": "Workflow id.",
@@ -13422,7 +23984,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/WorkflowUpdateV1"
+ "$ref": "#/components/schemas/WorkflowGraphReplaceV1"
}
}
},
@@ -13433,11 +23995,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/WorkflowV1"
+ "$ref": "#/components/schemas/WorkflowGraphV1"
}
}
},
- "description": "Updated workflow"
+ "description": "The graph as it now stands"
},
"401": {
"content": {
@@ -13467,7 +24029,7 @@
}
}
},
- "description": "`resource_not_found` — no workflow with this id in the authenticated project."
+ "description": "`resource_not_found` — no workflow with this id belongs to the authenticated project."
},
"409": {
"content": {
@@ -13477,7 +24039,7 @@
}
}
},
- "description": "`conflict` — the trigger cannot be changed while executions are running."
+ "description": "`conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow."
},
"422": {
"content": {
@@ -13523,16 +24085,16 @@
]
}
],
- "summary": "Update a workflow",
+ "summary": "Replace a workflow's step graph",
"tags": [
"Workflows"
]
}
},
- "/api/v1/workflows/{id}/executions": {
- "get": {
- "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.",
- "operationId": "v1ListWorkflowExecutions",
+ "/api/v1/workflows/{id}/pause": {
+ "post": {
+ "description": "Disables the workflow and cancels every `RUNNING`/`WAITING` execution inside it.\n\n`PATCH { \"enabled\": false }` stops NEW runs starting and leaves every in-flight contact walking the graph — the next delay still expires, the next email still sends. Pausing does both, and reports how many runs it stopped.\n\nCancelling is terminal: `resume` re-opens the workflow to new runs, it does not put the cancelled contacts back where they were.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.",
+ "operationId": "v1PauseWorkflow",
"parameters": [
{
"description": "Workflow id.",
@@ -13544,46 +24106,6 @@
"format": "uuid",
"type": "string"
}
- },
- {
- "in": "query",
- "name": "limit",
- "required": false,
- "schema": {
- "default": 20,
- "maximum": 100,
- "minimum": 1,
- "type": "integer"
- }
- },
- {
- "description": "Opaque cursor from a previous response's `next_cursor`.",
- "in": "query",
- "name": "after",
- "required": false,
- "schema": {
- "description": "Opaque cursor from a previous response's `next_cursor`.",
- "minLength": 1,
- "type": "string"
- }
- },
- {
- "description": "Return only executions in this state.",
- "in": "query",
- "name": "status",
- "required": false,
- "schema": {
- "description": "Return only executions in this state.",
- "enum": [
- "RUNNING",
- "WAITING",
- "COMPLETED",
- "EXITED",
- "FAILED",
- "CANCELLED"
- ],
- "type": "string"
- }
}
],
"responses": {
@@ -13591,11 +24113,11 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/WorkflowExecutionV1List"
+ "$ref": "#/components/schemas/WorkflowStateChangeV1"
}
}
},
- "description": "Execution list"
+ "description": "The workflow, and the number of runs this call cancelled"
},
"401": {
"content": {
@@ -13625,7 +24147,7 @@
}
}
},
- "description": "`resource_not_found` — no workflow with this id in the authenticated project."
+ "description": "`resource_not_found` — no workflow with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -13667,18 +24189,20 @@
},
{
"OAuth2": [
- "workflows:read"
+ "workflows:write"
]
}
],
- "summary": "List a workflow's executions",
+ "summary": "Pause a workflow and cancel its running executions",
"tags": [
"Workflows"
]
- },
+ }
+ },
+ "/api/v1/workflows/{id}/resume": {
"post": {
- "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.",
- "operationId": "v1StartWorkflowExecution",
+ "description": "Re-enables the workflow so its trigger matches again. `cancelled_executions` is always 0 here — resuming starts nothing and stops nothing.\n\nRefused with 422 while any step is still unconfigured, the same rule `PATCH { \"enabled\": true }` enforces: 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.",
+ "operationId": "v1ResumeWorkflow",
"parameters": [
{
"description": "Workflow id.",
@@ -13692,26 +24216,16 @@
}
}
],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/WorkflowExecutionStartV1"
- }
- }
- },
- "required": true
- },
"responses": {
- "201": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/WorkflowExecutionV1"
+ "$ref": "#/components/schemas/WorkflowStateChangeV1"
}
}
},
- "description": "Execution started"
+ "description": "The workflow, with `cancelled_executions` always 0"
},
"401": {
"content": {
@@ -13741,17 +24255,7 @@
}
}
},
- "description": "`resource_not_found` — no such workflow, or no such contact in this project."
- },
- "409": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/Problem"
- }
- }
- },
- "description": "`conflict` — the contact already has an execution and re-entry is not allowed."
+ "description": "`resource_not_found` — no workflow with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -13797,7 +24301,7 @@
]
}
],
- "summary": "Start a workflow for a contact",
+ "summary": "Resume a paused workflow",
"tags": [
"Workflows"
]
@@ -13805,7 +24309,7 @@
},
"/api/v1/workflows/{id}/stats": {
"get": {
- "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.",
+ "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\nThe workflow's own `name`, `enabled`, `trigger_type` and `step_count` travel with the counts, because the counts alone are ambiguous: no running executions means one thing on an enabled workflow and another on a paused one.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.",
"operationId": "v1GetWorkflowStats",
"parameters": [
{
@@ -13873,7 +24377,7 @@
}
}
},
- "description": "`resource_not_found` — no workflow with this id in the authenticated project."
+ "description": "`resource_not_found` — no workflow with this id belongs to the authenticated project."
},
"422": {
"content": {
@@ -14770,6 +25274,10 @@
"description": "Aggregate sending and engagement metrics. Every read is bounded to a window of at most 90 days and cached for 15 minutes.",
"name": "Analytics"
},
+ {
+ "description": "Why mail from one of your domains is or is not arriving: DNS identity, recent delivery outcomes, and one recipient's suppression state, composed into findings.",
+ "name": "Deliverability"
+ },
{
"description": "Current email usage against the monthly and daily limits the platform enforces.",
"name": "Usage"
@@ -14785,6 +25293,14 @@
{
"description": "Open email-validation endpoint (no auth required). Used by the marketing-site verifier.",
"name": "Verify"
+ },
+ {
+ "description": "Bulk email validation, billed per address: a bounded synchronous batch, and background runs over a whole list. Distinct from `Verify`, which is the one open, unauthenticated, single-address endpoint the marketing site calls.",
+ "name": "Validation"
+ },
+ {
+ "description": "The subjects you mail about, and what each contact has said about them. A topic answer is a standing decision rather than an audience filter: it applies whatever audience a campaign selects, so it cannot be routed around by choosing a different one.",
+ "name": "Topics"
}
],
"webhooks": {}
diff --git a/tests/support.py b/tests/support.py
index 8daa3cf..e763d02 100644
--- a/tests/support.py
+++ b/tests/support.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from collections.abc import Callable
+from collections.abc import Callable, Sequence
import httpx
@@ -84,6 +84,10 @@ def urls(self) -> list[str]:
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}
+def cursor_page(items: Sequence[object], *, next_cursor: str | None = None) -> dict[str, object]:
+ """Build an ``/api/v1`` cursor-list envelope: ``{data, has_more, next_cursor}``.
+
+ ``Sequence``, not ``list``: the per-resource page builders that delegate here
+ hand over their own element types, and ``list`` is invariant.
+ """
+ return {"data": list(items), "has_more": next_cursor is not None, "next_cursor": next_cursor}
diff --git a/tests/test_campaigns.py b/tests/test_campaigns.py
index 82c9c8d..a291367 100644
--- a/tests/test_campaigns.py
+++ b/tests/test_campaigns.py
@@ -260,3 +260,91 @@ def test_iter_list_surfaces_a_mid_walk_error_instead_of_swallowing_it():
with pytest.raises(SendlyRateLimitError) as caught:
next(iterator)
assert caught.value.error_code == "rate_limited"
+
+
+FAILURE = {
+ "id": "fail_1",
+ "contact_id": "ct_1",
+ "email": "bounced@example.com",
+ "reason": "HARD_BOUNCE",
+ "failed_at": "2026-09-01T00:00:00.000Z",
+}
+
+
+def test_list_failures_returns_the_bare_body_including_the_total_only_this_list_carries():
+ body = {"data": [FAILURE], "has_more": False, "next_cursor": None, "total": 4211}
+ rec = Recorder(json_response(200, body))
+ client = make_client(rec)
+
+ page = client.campaigns.list_failures("cmp_1")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/campaigns/cmp_1/failures"
+ assert rec.request.method == "GET"
+ # A v1 body arrives bare -- nothing unwrapped a {success, data} envelope,
+ # and `total` (unique to this list) survives.
+ assert page == body
+ assert page["total"] == 4211
+
+
+def test_list_failures_serializes_the_cursor_query():
+ rec = Recorder(
+ json_response(200, {"data": [], "has_more": False, "next_cursor": None, "total": 0})
+ )
+ client = make_client(rec)
+
+ client.campaigns.list_failures("cmp_1", {"limit": 50, "after": "fail_9"})
+
+ assert (
+ str(rec.request.url)
+ == "http://localhost/api/v1/campaigns/cmp_1/failures?limit=50&after=fail_9"
+ )
+
+
+def test_iter_list_failures_walks_two_pages_threads_the_cursor_and_stops():
+ rec = SequenceRecorder(
+ json_response(200, cursor_page([{"id": "fail_1"}], next_cursor="fail_1")),
+ json_response(200, cursor_page([{"id": "fail_2"}, {"id": "fail_3"}])),
+ )
+ client = make_client(rec)
+
+ ids = [row["id"] for row in client.campaigns.iter_list_failures("cmp_1")]
+
+ assert ids == ["fail_1", "fail_2", "fail_3"]
+ # Exactly two requests: a third would trip SequenceRecorder's assert.
+ assert rec.urls == [
+ "http://localhost/api/v1/campaigns/cmp_1/failures",
+ "http://localhost/api/v1/campaigns/cmp_1/failures?after=fail_1",
+ ]
+
+
+def test_retry_failed_posts_with_no_body_and_returns_the_queued_count():
+ rec = Recorder(json_response(200, {"id": "cmp_1", "queued": 12}))
+ client = make_client(rec)
+
+ ack = client.campaigns.retry_failed("cmp_1")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/campaigns/cmp_1/retry-failed"
+ assert rec.request.method == "POST"
+ # The route takes no body -- sending one would be a contract change.
+ assert rec.request.content == b""
+ assert ack == {"id": "cmp_1", "queued": 12}
+
+
+def test_retry_failed_raises_conflict_when_a_retry_is_already_running():
+ rec = Recorder(
+ problem_response(
+ 409,
+ {
+ "type": "https://docs.sendly.now/errors/conflict",
+ "title": "Conflict",
+ "status": 409,
+ "detail": "A retry is already running for this campaign.",
+ "code": "conflict",
+ },
+ )
+ )
+ client = make_client(rec)
+
+ with pytest.raises(SendlyConflictError) as caught:
+ client.campaigns.retry_failed("cmp_1")
+ assert caught.value.error_code == "conflict"
diff --git a/tests/test_contacts.py b/tests/test_contacts.py
index eb95afc..7fe09bf 100644
--- a/tests/test_contacts.py
+++ b/tests/test_contacts.py
@@ -7,7 +7,22 @@
import pytest
from sendly import SendlyNotFoundError, SendlyValidationError
-from support import Recorder, json_response, make_client
+from support import (
+ Recorder,
+ SequenceRecorder,
+ cursor_page,
+ json_response,
+ make_client,
+)
+
+CONTACT_V1 = {
+ "id": "con_1",
+ "email": "x@y.com",
+ "subscribed": True,
+ "custom_fields": {"plan": "pro"},
+ "created_at": "2026-01-01T00:00:00.000Z",
+ "updated_at": "2026-01-01T00:00:00.000Z",
+}
def test_create_posts_and_unwraps_data():
@@ -95,3 +110,111 @@ def test_bulk_delete_sends_delete_with_body():
assert str(rec.request.url) == "http://localhost/api/contacts/bulk"
assert rec.request.method == "DELETE"
assert json.loads(rec.request.content) == {"emails": ["a@b.com"]}
+
+
+def test_create_v1_posts_the_bare_body_and_returns_it_unwrapped():
+ rec = Recorder(json_response(201, CONTACT_V1))
+ client = make_client(rec)
+
+ result = client.contacts.create_v1({"email": "x@y.com", "custom_fields": {"plan": "pro"}})
+
+ assert str(rec.request.url) == "http://localhost/api/v1/contacts"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {
+ "email": "x@y.com",
+ "custom_fields": {"plan": "pro"},
+ }
+ # v1 answers a bare body -- nothing is unwrapped, the whole document arrives.
+ assert result == CONTACT_V1
+
+
+def test_get_update_and_delete_v1_hit_the_id_path():
+ rec = Recorder(json_response(200, CONTACT_V1))
+ client = make_client(rec)
+ assert client.contacts.get_v1("con_1") == CONTACT_V1
+ assert str(rec.request.url) == "http://localhost/api/v1/contacts/con_1"
+ assert rec.request.method == "GET"
+
+ rec = Recorder(json_response(200, CONTACT_V1))
+ client = make_client(rec)
+ client.contacts.update_v1("con_1", {"custom_fields": {"plan": "enterprise"}})
+ assert str(rec.request.url) == "http://localhost/api/v1/contacts/con_1"
+ assert rec.request.method == "PATCH"
+ assert json.loads(rec.request.content) == {"custom_fields": {"plan": "enterprise"}}
+
+ # Unlike the legacy delete, the acknowledgement is handed back, not discarded.
+ rec = Recorder(json_response(200, {"id": "con_1", "deleted": True}))
+ client = make_client(rec)
+ assert client.contacts.delete_v1("con_1") == {"id": "con_1", "deleted": True}
+ assert rec.request.method == "DELETE"
+
+
+def test_list_v1_serializes_search_subscribed_and_cursor_params():
+ page = cursor_page([CONTACT_V1])
+ rec = Recorder(json_response(200, page))
+ client = make_client(rec)
+
+ assert client.contacts.list_v1({"limit": 10, "search": "ada", "subscribed": "false"}) == page
+ assert (
+ str(rec.request.url)
+ == "http://localhost/api/v1/contacts?limit=10&search=ada&subscribed=false"
+ )
+
+
+def test_topic_preferences_gets_the_topics_sub_path():
+ preferences = {
+ "contact_id": "con_1",
+ "subscribed": False,
+ "topics": [
+ {
+ "topic_id": "top_1",
+ "key": "product-news",
+ "name": "Product news",
+ "subscribed": True,
+ "pending": False,
+ }
+ ],
+ }
+ rec = Recorder(json_response(200, preferences))
+ client = make_client(rec)
+
+ result = client.contacts.topic_preferences("con_1")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/contacts/con_1/topics"
+ assert rec.request.method == "GET"
+ # The global opt-out outranks the per-topic answers; both must survive the trip.
+ assert result["subscribed"] is False
+ assert result["topics"][0]["key"] == "product-news"
+
+
+def test_iter_list_v1_walks_every_page_and_carries_the_filter_forward():
+ 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)
+
+ assert [c["id"] for c in client.contacts.iter_list_v1({"search": "ada"})] == [
+ "con_1",
+ "con_2",
+ ]
+ assert rec.urls == [
+ "http://localhost/api/v1/contacts?search=ada",
+ "http://localhost/api/v1/contacts?search=ada&after=cur_2",
+ ]
+
+
+def test_contact_id_is_percent_encoded_into_the_v1_path():
+ rec = Recorder(json_response(200, CONTACT_V1))
+ client = make_client(rec)
+ client.contacts.get_v1("a/b")
+ assert str(rec.request.url) == "http://localhost/api/v1/contacts/a%2Fb"
+
+
+def test_get_v1_raises_not_found_on_a_404_problem_document():
+ rec = Recorder(
+ json_response(404, {"error": {"message": "no such contact", "code": "not_found"}})
+ )
+ client = make_client(rec)
+ with pytest.raises(SendlyNotFoundError):
+ client.contacts.get_v1("con_missing")
diff --git a/tests/test_contract.py b/tests/test_contract.py
index e504fdf..08abb37 100644
--- a/tests/test_contract.py
+++ b/tests/test_contract.py
@@ -134,9 +134,17 @@ def _resolve_ref(spec: dict[str, Any], node: Any) -> Any:
return node
-#: Members that identify the ``/api/v1`` cursor-list envelope. No ``total`` --
+#: Members that identify a ``/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"})
+#:
+#: ONE of them, as of the 1.1 contract. There were two through 1.0: topics and
+#: validation results answered ``cursor`` and took ``cursor``, so the shared
+#: walker sent a parameter they ignored and read a field they never returned, and
+#: this guard had to recognise the second shape or it read those endpoints as "not
+#: paginated" and flagged their own iterators as stray. Detection stays by SHAPE
+#: rather than by an endpoint list, so a resource that reintroduces a second
+#: dialect fails here instead of being assumed away.
+CURSOR_ENVELOPES = (frozenset({"data", "has_more", "next_cursor"}),)
def _cursor_list_operations(spec: dict[str, Any]) -> set[tuple[str, str]]:
@@ -156,7 +164,7 @@ def _cursor_list_operations(spec: dict[str, Any]) -> set[tuple[str, str]]:
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):
+ if any(envelope.issubset(properties) for envelope in CURSOR_ENVELOPES):
cursor_ops.add((verb, norm))
return cursor_ops
diff --git a/tests/test_deliverability.py b/tests/test_deliverability.py
new file mode 100644
index 0000000..50cbe81
--- /dev/null
+++ b/tests/test_deliverability.py
@@ -0,0 +1,153 @@
+"""Deliverability resource tests (``/api/v1``)."""
+
+from __future__ import annotations
+
+from support import Recorder, SequenceRecorder, cursor_page, json_response, make_client
+
+DIAGNOSIS = {
+ "domain": "example.com",
+ "address": None,
+ "checked_at": "2026-09-01T00:00:00.000Z",
+ "identity": {"registered": True, "verified": False, "dkim_status": "FAILED"},
+ "suppression": None,
+ "recent_delivery": {"window_days": 7, "scope": "project", "sent": 500, "bounced": 40},
+ "findings": [
+ {
+ "code": "dkim_failed",
+ "severity": "critical",
+ "summary": "DKIM is failing.",
+ "remedy": "Re-add the DNS records.",
+ }
+ ],
+}
+
+
+def domain_stats(domain: str, day: str) -> dict[str, object]:
+ """One recipient domain's outcomes on one UTC day."""
+ return {
+ "domain": domain,
+ "day": day,
+ "sent": 100,
+ "delivered": 92,
+ "bounced": 7,
+ "complained": 1,
+ "opened": 40,
+ "computed_at": "2026-09-01T00:00:00.000Z",
+ }
+
+
+def dmarc_report(id: str) -> dict[str, object]:
+ """One DMARC aggregate report."""
+ return {
+ "id": id,
+ "report_id": f"rpt_{id}",
+ "org_name": "google.com",
+ "policy_domain": "example.com",
+ "range_begin": "2026-09-01T00:00:00.000Z",
+ "range_end": "2026-09-02T00:00:00.000Z",
+ "total_count": 10,
+ "pass_count": 9,
+ "fail_count": 1,
+ "sources": [],
+ "received_at": "2026-09-02T06:00:00.000Z",
+ }
+
+
+def test_diagnose_serializes_every_query_parameter():
+ rec = Recorder(json_response(200, DIAGNOSIS))
+ client = make_client(rec)
+
+ result = client.deliverability.diagnose(
+ {"domain": "example.com", "address": "person@gmail.com", "window_days": 14}
+ )
+
+ assert result == DIAGNOSIS
+ assert str(rec.request.url) == (
+ "http://localhost/api/v1/deliverability/diagnose"
+ "?domain=example.com&address=person%40gmail.com&window_days=14"
+ )
+ assert rec.request.method == "GET"
+
+
+def test_diagnose_reports_findings_and_a_project_wide_delivery_scope():
+ rec = Recorder(json_response(200, DIAGNOSIS))
+ client = make_client(rec)
+
+ result = client.deliverability.diagnose({"domain": "example.com"})
+
+ assert result["findings"][0]["code"] == "dkim_failed"
+ # `recent_delivery` is project-wide, and says so in its own field.
+ assert result["recent_delivery"]["scope"] == "project"
+
+
+def test_list_domain_stats_hits_the_recipient_domain_rollup_with_its_filters():
+ page = cursor_page([domain_stats("gmail.com", "2026-09-01")])
+ rec = Recorder(json_response(200, page))
+ client = make_client(rec)
+
+ # Returned as-is: the envelope, not its `data` array.
+ assert (
+ client.deliverability.list_domain_stats({"limit": 50, "days": 7, "domain": "gmail.com"})
+ == page
+ )
+ assert str(rec.request.url) == (
+ "http://localhost/api/v1/deliverability/domains?limit=50&days=7&domain=gmail.com"
+ )
+
+
+def test_iter_list_domain_stats_walks_two_pages_on_after():
+ rec = SequenceRecorder(
+ json_response(
+ 200, cursor_page([domain_stats("gmail.com", "2026-09-02")], next_cursor="cur_2")
+ ),
+ json_response(200, cursor_page([domain_stats("outlook.com", "2026-09-02")])),
+ )
+ client = make_client(rec)
+
+ domains = [row["domain"] for row in client.deliverability.iter_list_domain_stats({"days": 2})]
+
+ assert domains == ["gmail.com", "outlook.com"]
+ assert rec.urls == [
+ "http://localhost/api/v1/deliverability/domains?days=2",
+ "http://localhost/api/v1/deliverability/domains?days=2&after=cur_2",
+ ]
+
+
+def test_list_dmarc_reports_hits_the_dmarc_path_with_its_filters():
+ page = cursor_page([dmarc_report("dmr_1")])
+ rec = Recorder(json_response(200, page))
+ client = make_client(rec)
+
+ assert (
+ client.deliverability.list_dmarc_reports({"limit": 10, "days": 90, "domain": "example.com"})
+ == page
+ )
+ assert str(rec.request.url) == (
+ "http://localhost/api/v1/deliverability/dmarc?limit=10&days=90&domain=example.com"
+ )
+
+
+def test_an_empty_dmarc_page_is_a_well_formed_answer():
+ # No reports are stored for a domain the project has not registered, so the
+ # correct-and-empty page is not a bug.
+ rec = Recorder(json_response(200, cursor_page([])))
+ client = make_client(rec)
+
+ page = client.deliverability.list_dmarc_reports()
+
+ assert page["data"] == []
+ assert page["has_more"] is False
+
+
+def test_iter_list_dmarc_reports_walks_two_pages_and_stops():
+ rec = SequenceRecorder(
+ json_response(200, cursor_page([dmarc_report("dmr_1")], next_cursor="cur_2")),
+ json_response(200, cursor_page([dmarc_report("dmr_2")])),
+ )
+ client = make_client(rec)
+
+ assert [r["id"] for r in client.deliverability.iter_list_dmarc_reports()] == ["dmr_1", "dmr_2"]
+ assert rec.urls == [
+ "http://localhost/api/v1/deliverability/dmarc",
+ "http://localhost/api/v1/deliverability/dmarc?after=cur_2",
+ ]
diff --git a/tests/test_domains.py b/tests/test_domains.py
index cc4860d..210c066 100644
--- a/tests/test_domains.py
+++ b/tests/test_domains.py
@@ -2,10 +2,30 @@
from __future__ import annotations
+import json
+
import pytest
-from sendly import SendlyPermissionError
-from support import Recorder, json_response, make_client
+from sendly import SendlyConflictError, SendlyPermissionError
+from support import (
+ Recorder,
+ SequenceRecorder,
+ cursor_page,
+ json_response,
+ make_client,
+ problem_response,
+)
+
+DOMAIN_V1 = {
+ "id": "dom_1",
+ "domain": "mail.example.com",
+ "verified": True,
+ "dkim_verified": False,
+ "mail_from_domain": "sendly.mail.example.com",
+ "mail_from_domain_status": "Success",
+ "stream": "TRANSACTIONAL",
+ "stream_default": True,
+}
def test_start_setup_posts_the_session_route_and_returns_the_link_verbatim():
@@ -27,7 +47,7 @@ def test_start_setup_posts_the_session_route_and_returns_the_link_verbatim():
def test_create_posts_domains_and_unwraps():
rec = Recorder(
- json_response(201, {"success": True, "data": {"id": "d_1", "name": "mail.example.com"}})
+ json_response(201, {"success": True, "data": {"id": "d_1", "domain": "mail.example.com"}})
)
client = make_client(rec)
result = client.domains.create({"domain": "mail.example.com"})
@@ -44,19 +64,55 @@ def test_list_gets_domains():
def test_verify_posts_verify_path():
- rec = Recorder(json_response(200, {"success": True, "data": {"status": "PENDING"}}))
+ # `status` is SES's own raw DKIM state; the per-record checks are their own fields.
+ rec = Recorder(
+ json_response(
+ 200,
+ {
+ "success": True,
+ "data": {
+ "domain": "mail.example.com",
+ "status": "Pending",
+ "verified": False,
+ "dkimStatus": "PENDING",
+ "spfStatus": "NOT_CHECKED",
+ "dmarcStatus": "NOT_CHECKED",
+ "mailFromDomain": None,
+ },
+ },
+ )
+ )
client = make_client(rec)
client.domains.verify("d_1")
assert str(rec.request.url) == "http://localhost/api/domains/d_1/verify"
assert rec.request.method == "POST"
-def test_get_verification_gets_verify_path():
- rec = Recorder(json_response(200, {"success": True, "data": {"status": "VERIFIED"}}))
+def test_get_verification_reports_each_record_type():
+ rec = Recorder(
+ json_response(
+ 200,
+ {
+ "success": True,
+ "data": {
+ "domain": "mail.example.com",
+ "status": "Success",
+ "verified": True,
+ "dkimStatus": "VERIFIED",
+ "spfStatus": "VERIFIED",
+ "dmarcStatus": "NOT_CHECKED",
+ "mailFromDomain": "bounce.mail.example.com",
+ },
+ },
+ )
+ )
client = make_client(rec)
- client.domains.get_verification("d_1")
+ status = client.domains.get_verification("d_1")
assert str(rec.request.url) == "http://localhost/api/domains/d_1/verify"
assert rec.request.method == "GET"
+ # DMARC unchecked while DKIM and SPF pass -- one status per record type, not one verdict.
+ assert status["dkimStatus"] == "VERIFIED"
+ assert status["dmarcStatus"] == "NOT_CHECKED"
def test_create_raises_permission_error_on_403():
@@ -68,3 +124,132 @@ def test_create_raises_permission_error_on_403():
client = make_client(rec)
with pytest.raises(SendlyPermissionError):
client.domains.create({"domain": "x.com"})
+
+
+def test_assign_stream_patches_the_legacy_path_with_the_camel_case_body():
+ record = {
+ "id": "d_1",
+ "domain": "mail.example.com",
+ "stream": "MARKETING",
+ "streamDefault": True,
+ }
+ rec = Recorder(json_response(200, {"success": True, "data": record}))
+ client = make_client(rec)
+
+ client.domains.assign_stream(
+ "d_1",
+ {
+ "stream": "MARKETING",
+ "streamDefault": True,
+ "defaultFromAddress": "news@mail.example.com",
+ },
+ )
+
+ assert str(rec.request.url) == "http://localhost/api/domains/d_1"
+ assert rec.request.method == "PATCH"
+ assert json.loads(rec.request.content) == {
+ "stream": "MARKETING",
+ "streamDefault": True,
+ "defaultFromAddress": "news@mail.example.com",
+ }
+
+
+def test_assign_stream_unwraps_the_legacy_envelope():
+ record = {"id": "d_1", "stream": None, "streamDefault": False}
+ rec = Recorder(json_response(200, {"success": True, "data": record}))
+ client = make_client(rec)
+
+ # The `{success, data}` wrapper is peeled off -- the record itself is returned.
+ assert client.domains.assign_stream("d_1", {"stream": None}) == record
+
+
+def test_create_v1_posts_the_bare_body():
+ rec = Recorder(json_response(201, {**DOMAIN_V1, "verified": False}))
+ client = make_client(rec)
+
+ created = client.domains.create_v1({"domain": "mail.example.com", "region": "eu-west-1"})
+
+ assert str(rec.request.url) == "http://localhost/api/v1/domains"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {
+ "domain": "mail.example.com",
+ "region": "eu-west-1",
+ }
+ # Nothing is verified until the DKIM records resolve.
+ assert created["verified"] is False
+
+
+def test_v1_reads_return_the_bare_body_without_unwrapping():
+ rec = Recorder(json_response(200, DOMAIN_V1))
+ client = make_client(rec)
+
+ # A v1 body has no `data` key to unwrap, so unwrapping would lose the record.
+ assert client.domains.get_v1("dom_1") == DOMAIN_V1
+ assert str(rec.request.url) == "http://localhost/api/v1/domains/dom_1"
+ assert rec.request.method == "GET"
+
+
+def test_verify_v1_posts_the_verify_subpath_with_no_body():
+ rec = Recorder(json_response(200, {**DOMAIN_V1, "dkim_verified": True}))
+ client = make_client(rec)
+
+ refreshed = client.domains.verify_v1("dom_1")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/domains/dom_1/verify"
+ assert rec.request.method == "POST"
+ # It reports what SES now sees; it edits none of the domain's own fields.
+ assert rec.request.content == b""
+ assert refreshed["dkim_verified"] is True
+
+
+def test_delete_v1_returns_the_deletion_receipt():
+ rec = Recorder(json_response(200, {"id": "dom_1", "deleted": True}))
+ client = make_client(rec)
+
+ assert client.domains.delete_v1("dom_1") == {"id": "dom_1", "deleted": True}
+ assert str(rec.request.url) == "http://localhost/api/v1/domains/dom_1"
+ assert rec.request.method == "DELETE"
+
+
+def test_list_v1_serializes_the_cursor_query():
+ page = cursor_page([DOMAIN_V1])
+ rec = Recorder(json_response(200, page))
+ client = make_client(rec)
+
+ assert client.domains.list_v1({"limit": 10, "after": "cur_dom"}) == page
+ assert str(rec.request.url) == "http://localhost/api/v1/domains?limit=10&after=cur_dom"
+
+
+def test_iter_list_v1_walks_every_page():
+ rec = SequenceRecorder(
+ json_response(200, cursor_page([{"id": "dom_1"}], next_cursor="cur_2")),
+ json_response(200, cursor_page([{"id": "dom_2"}, {"id": "dom_3"}])),
+ )
+ client = make_client(rec)
+
+ ids = [d["id"] for d in client.domains.iter_list_v1({"limit": 1})]
+
+ assert ids == ["dom_1", "dom_2", "dom_3"]
+ assert rec.urls == [
+ "http://localhost/api/v1/domains?limit=1",
+ "http://localhost/api/v1/domains?limit=1&after=cur_2",
+ ]
+
+
+def test_delete_v1_raises_conflict_while_the_domain_is_still_sending():
+ rec = Recorder(
+ problem_response(
+ 409,
+ {
+ "type": "https://docs.sendly.now/errors/conflict",
+ "title": "Conflict",
+ "status": 409,
+ "code": "conflict",
+ "detail": "Domain is still used by 1 active campaign.",
+ },
+ )
+ )
+ client = make_client(rec)
+
+ with pytest.raises(SendlyConflictError):
+ client.domains.delete_v1("dom_1")
diff --git a/tests/test_lists.py b/tests/test_lists.py
index 5fc7c95..946ca1d 100644
--- a/tests/test_lists.py
+++ b/tests/test_lists.py
@@ -1,4 +1,4 @@
-"""Lists resource tests (legacy ``/api/lists`` subscribe / unsubscribe)."""
+"""Lists resource tests (legacy subscribe / unsubscribe, plus the ``/api/v1`` half)."""
from __future__ import annotations
@@ -7,7 +7,26 @@
import pytest
from sendly import SendlyConflictError
-from support import Recorder, json_response, make_client
+from support import (
+ Recorder,
+ SequenceRecorder,
+ cursor_page,
+ json_response,
+ make_client,
+ problem_response,
+)
+
+LIST_V1 = {
+ "id": "lst_1",
+ "name": "Weekly digest",
+ "description": None,
+ "double_opt_in": True,
+ "confirmation_template_id": None,
+ "redirect_url": None,
+ "member_count": 3,
+ "created_at": "2026-01-01T00:00:00.000Z",
+ "updated_at": "2026-01-01T00:00:00.000Z",
+}
def test_subscribe_posts_the_email_and_unwraps_the_membership():
@@ -121,3 +140,116 @@ def test_list_id_is_percent_encoded_into_the_path():
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"
+
+
+def test_create_v1_posts_the_bare_body_and_returns_it_unwrapped():
+ rec = Recorder(json_response(201, LIST_V1))
+ client = make_client(rec)
+
+ result = client.lists.create_v1({"name": "Weekly digest", "double_opt_in": True})
+
+ assert str(rec.request.url) == "http://localhost/api/v1/lists"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {"name": "Weekly digest", "double_opt_in": True}
+ # v1 answers a bare body -- nothing is unwrapped, the whole document arrives.
+ assert result == LIST_V1
+
+
+def test_get_update_and_delete_v1_hit_the_id_path():
+ rec = Recorder(json_response(200, LIST_V1))
+ client = make_client(rec)
+ assert client.lists.get_v1("lst_1") == LIST_V1
+ assert str(rec.request.url) == "http://localhost/api/v1/lists/lst_1"
+ assert rec.request.method == "GET"
+
+ rec = Recorder(json_response(200, LIST_V1))
+ client = make_client(rec)
+ client.lists.update_v1("lst_1", {"name": "Renamed", "redirect_url": None})
+ assert str(rec.request.url) == "http://localhost/api/v1/lists/lst_1"
+ assert rec.request.method == "PATCH"
+ assert json.loads(rec.request.content) == {"name": "Renamed", "redirect_url": None}
+
+ rec = Recorder(json_response(200, {"id": "lst_1", "deleted": True}))
+ client = make_client(rec)
+ assert client.lists.delete_v1("lst_1") == {"id": "lst_1", "deleted": True}
+ assert rec.request.method == "DELETE"
+
+
+def test_list_v1_serializes_the_cursor_params():
+ page = cursor_page([LIST_V1])
+ rec = Recorder(json_response(200, page))
+ client = make_client(rec)
+
+ assert client.lists.list_v1({"limit": 25, "after": "cur_lst"}) == page
+ assert str(rec.request.url) == "http://localhost/api/v1/lists?limit=25&after=cur_lst"
+
+
+def test_iter_list_v1_walks_every_page_and_carries_the_filter_forward():
+ rec = SequenceRecorder(
+ json_response(200, cursor_page([{"id": "lst_1"}], next_cursor="cur_2")),
+ json_response(200, cursor_page([{"id": "lst_2"}])),
+ )
+ client = make_client(rec)
+
+ assert [item["id"] for item in client.lists.iter_list_v1({"limit": 1})] == ["lst_1", "lst_2"]
+ assert rec.urls == [
+ "http://localhost/api/v1/lists?limit=1",
+ "http://localhost/api/v1/lists?limit=1&after=cur_2",
+ ]
+
+
+def test_start_validation_run_posts_the_validation_runs_sub_path():
+ run = {
+ "id": "vrun_1",
+ "list_id": "lst_1",
+ "status": "pending",
+ "processed_count": 0,
+ "deliverable_count": 0,
+ "undeliverable_count": 0,
+ "risky_count": 0,
+ "started_at": None,
+ "completed_at": None,
+ "failure_reason": None,
+ "created_at": "2026-01-01T00:00:00.000Z",
+ }
+ rec = Recorder(json_response(202, run))
+ client = make_client(rec)
+
+ started = client.lists.start_validation_run("lst_1")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/lists/lst_1/validation-runs"
+ assert rec.request.method == "POST"
+ # Billed per address checked, so the caller must see the run it just paid to start.
+ assert started == run
+
+
+def test_list_id_is_percent_encoded_into_the_v1_paths():
+ rec = Recorder(json_response(200, LIST_V1))
+ client = make_client(rec)
+ client.lists.get_v1("lst/1")
+ assert str(rec.request.url) == "http://localhost/api/v1/lists/lst%2F1"
+
+ rec = Recorder(json_response(202, {"id": "vrun_1"}))
+ client = make_client(rec)
+ client.lists.start_validation_run("lst/1")
+ assert str(rec.request.url) == "http://localhost/api/v1/lists/lst%2F1/validation-runs"
+
+
+def test_delete_v1_surfaces_a_409_problem_document_as_a_conflict():
+ rec = Recorder(
+ problem_response(
+ 409,
+ {
+ "type": "https://docs.sendly.now/errors/conflict",
+ "title": "Conflict",
+ "status": 409,
+ "code": "conflict",
+ "detail": "List is referenced by 1 campaign.",
+ },
+ )
+ )
+ client = make_client(rec)
+
+ with pytest.raises(SendlyConflictError) as caught:
+ client.lists.delete_v1("lst_1")
+ assert caught.value.error_code == "conflict"
diff --git a/tests/test_mailboxes.py b/tests/test_mailboxes.py
index 4ac2b51..815ac8b 100644
--- a/tests/test_mailboxes.py
+++ b/tests/test_mailboxes.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import json
+
import pytest
from sendly import SendlyNotFoundError
@@ -97,3 +99,99 @@ def test_unknown_mailbox_raises_not_found():
with pytest.raises(SendlyNotFoundError):
client.mailboxes.get("nope")
+
+
+def test_send_message_posts_the_composed_body_and_unwraps_the_envelope():
+ receipt = {"submitted": True, "conversationId": "cv_1", "messageId": "msg_1"}
+ rec = Recorder(json_response(201, {"success": True, "data": receipt}))
+ client = make_client(rec)
+
+ submitted = client.mailboxes.send_message(
+ "mb_1",
+ {
+ "to": ["customer@example.com"],
+ "subject": "Your order",
+ "body": "It shipped this morning.",
+ },
+ )
+
+ assert str(rec.request.url) == "http://localhost/api/mailboxes/mb_1/messages"
+ assert rec.request.method == "POST"
+ # Unlike the v1 resources, this legacy route's {success, data} wrapper is stripped.
+ assert submitted == receipt
+
+
+def test_send_message_takes_no_from_field__the_mailbox_in_the_path_is_the_sender():
+ rec = Recorder(
+ json_response(
+ 201,
+ {
+ "success": True,
+ "data": {"submitted": True, "conversationId": "cv_1", "messageId": "msg_1"},
+ },
+ )
+ )
+ client = make_client(rec)
+
+ body = {
+ "to": ["customer@example.com"],
+ "bcc": ["archive@example.com"],
+ "subject": "Your order",
+ "body": "It shipped this morning.",
+ }
+ client.mailboxes.send_message("mb_1", body)
+
+ sent = json.loads(rec.request.content)
+ assert sent == body
+ assert "from" not in sent
+
+
+def test_draft_message_posts_to_drafts_unwraps_and_reports_sent_false():
+ draft = {"subject": "Your order shipped", "body": "Hi there --", "subjects": [], "sent": False}
+ rec = Recorder(json_response(200, {"success": True, "data": draft}))
+ client = make_client(rec)
+
+ result = client.mailboxes.draft_message("mb_1", {"mode": "draft", "brief": "order shipped"})
+
+ assert str(rec.request.url) == "http://localhost/api/mailboxes/mb_1/drafts"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {"mode": "draft", "brief": "order shipped"}
+ # The whole safety story of this pair: drafting never mails anybody.
+ assert result == draft
+ assert result["sent"] is False
+
+
+def test_draft_message_in_subject_mode_returns_alternatives_not_a_send_receipt():
+ draft = {
+ "subject": None,
+ "body": None,
+ "subjects": ["Shipped!", "On its way"],
+ "sent": False,
+ }
+ rec = Recorder(json_response(200, {"success": True, "data": draft}))
+ client = make_client(rec)
+
+ result = client.mailboxes.draft_message(
+ "mb_1", {"mode": "subject", "draft": "your order shipped"}
+ )
+
+ assert result["subjects"] == ["Shipped!", "On its way"]
+ # A draft carries no conversation or message id -- nothing was created.
+ assert "messageId" not in result
+
+
+def test_composition_routes_percent_encode_the_mailbox_id_too():
+ rec = Recorder(
+ json_response(
+ 200,
+ {
+ "success": True,
+ "data": {"subject": None, "body": None, "subjects": [], "sent": False},
+ },
+ )
+ )
+ client = make_client(rec)
+
+ client.mailboxes.draft_message("mb/../evil", {"mode": "draft"})
+
+ assert str(rec.request.url) == "http://localhost/api/mailboxes/mb%2F..%2Fevil/drafts"
diff --git a/tests/test_snippets.py b/tests/test_snippets.py
new file mode 100644
index 0000000..78f4f45
--- /dev/null
+++ b/tests/test_snippets.py
@@ -0,0 +1,103 @@
+"""Snippets resource tests."""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from sendly import SendlyConflictError
+from support import Recorder, json_response, make_client
+
+SNIPPET = {
+ "id": "snp_1",
+ "projectId": "prj_1",
+ "name": "footer",
+ "description": None,
+ "body": "Unsubscribe
",
+ "createdAt": "2026-01-01T00:00:00.000Z",
+ "updatedAt": "2026-01-01T00:00:00.000Z",
+}
+
+
+def test_create_posts_snippets_and_unwraps_the_envelope():
+ rec = Recorder(json_response(201, {"success": True, "data": SNIPPET}))
+ client = make_client(rec)
+
+ created = client.snippets.create({"name": "footer", "body": "Unsubscribe
"})
+
+ assert str(rec.request.url) == "http://localhost/api/snippets"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {"name": "footer", "body": "Unsubscribe
"}
+ # Legacy dialect: the caller gets the record, never the {success, data} wrapper.
+ assert created == SNIPPET
+
+
+def test_list_serializes_limit_cursor_and_search():
+ rec = Recorder(
+ json_response(200, {"success": True, "data": {"data": [], "total": 0, "hasMore": False}})
+ )
+ client = make_client(rec)
+
+ client.snippets.list({"limit": 25, "cursor": "snp_50", "search": "footer"})
+
+ url = str(rec.request.url)
+ assert "limit=25" in url
+ assert "cursor=snp_50" in url
+ assert "search=footer" in url
+
+
+def test_list_keeps_the_envelope():
+ body = {
+ "success": True,
+ "data": {"data": [SNIPPET], "total": 1, "cursor": "snp_1", "hasMore": True},
+ }
+ rec = Recorder(json_response(200, body))
+ client = make_client(rec)
+
+ # Unlike create/get/update, the list response is returned whole.
+ assert client.snippets.list() == body
+ assert rec.request.method == "GET"
+
+
+def test_get_unwraps_to_the_record_at_the_id_path():
+ rec = Recorder(json_response(200, {"success": True, "data": SNIPPET}))
+ client = make_client(rec)
+
+ assert client.snippets.get("snp_1") == SNIPPET
+ assert str(rec.request.url) == "http://localhost/api/snippets/snp_1"
+ assert rec.request.method == "GET"
+
+
+def test_update_patches_and_unwraps():
+ rec = Recorder(json_response(200, {"success": True, "data": {**SNIPPET, "name": "footer_v2"}}))
+ client = make_client(rec)
+
+ updated = client.snippets.update("snp_1", {"name": "footer_v2"})
+
+ assert str(rec.request.url) == "http://localhost/api/snippets/snp_1"
+ assert rec.request.method == "PATCH"
+ assert json.loads(rec.request.content) == {"name": "footer_v2"}
+ assert updated["name"] == "footer_v2"
+
+
+def test_delete_discards_200_id_body():
+ # The API returns 200 with {success, data: {id}}; the SDK discards it -> None.
+ rec = Recorder(json_response(200, {"success": True, "data": {"id": "snp_1"}}))
+ client = make_client(rec)
+
+ assert client.snippets.delete("snp_1") is None
+ assert str(rec.request.url) == "http://localhost/api/snippets/snp_1"
+ assert rec.request.method == "DELETE"
+
+
+def test_create_raises_conflict_when_the_name_is_taken():
+ rec = Recorder(
+ json_response(
+ 409, {"error": {"message": "snippet name already exists", "code": "conflict"}}
+ )
+ )
+ client = make_client(rec)
+
+ with pytest.raises(SendlyConflictError):
+ client.snippets.create({"name": "footer", "body": "x"})
diff --git a/tests/test_suppression.py b/tests/test_suppression.py
index b4d302d..e1ba040 100644
--- a/tests/test_suppression.py
+++ b/tests/test_suppression.py
@@ -2,10 +2,27 @@
from __future__ import annotations
+import json
+
import pytest
-from sendly import SendlyServerError
-from support import Recorder, empty_response, json_response, make_client
+from sendly import SendlyNotFoundError, SendlyServerError
+from support import (
+ Recorder,
+ SequenceRecorder,
+ cursor_page,
+ empty_response,
+ json_response,
+ make_client,
+ problem_response,
+)
+
+SUPPRESSION_V1 = {
+ "email": "spam@x.com",
+ "reason": "MANUAL",
+ "source": "API",
+ "created_at": "2026-01-01T00:00:00.000Z",
+}
def test_add_posts_suppression():
@@ -17,13 +34,17 @@ def test_add_posts_suppression():
assert str(rec.request.url) == "http://localhost/api/suppression"
-def test_list_serializes_reason_filter():
- rec = Recorder(json_response(200, {"success": True, "data": {"items": []}}))
+def test_list_serializes_reason_filter_and_hands_back_the_bare_body():
+ # Alone among the legacy reads, this route answers no {success, data}
+ # envelope -- the page IS the body, so there is nothing to unwrap.
+ rec = Recorder(json_response(200, {"items": [], "nextCursor": None}))
client = make_client(rec)
- client.suppression.list({"reason": "MANUAL", "limit": 100})
+ page = client.suppression.list({"reason": "MANUAL", "limit": 100})
url = str(rec.request.url)
assert "reason=MANUAL" in url
assert "limit=100" in url
+ assert page["items"] == []
+ assert page["nextCursor"] is None
def test_get_percent_encodes_email_path_segment():
@@ -47,3 +68,91 @@ def test_add_raises_server_error_on_500():
client = make_client(rec)
with pytest.raises(SendlyServerError):
client.suppression.add({"email": "x@y.com", "reason": "MANUAL"})
+
+
+def test_create_v1_posts_the_plural_path_and_returns_the_bare_record():
+ rec = Recorder(json_response(201, SUPPRESSION_V1))
+ client = make_client(rec)
+
+ result = client.suppression.create_v1({"email": "spam@x.com", "reason": "COMPLAINT"})
+
+ # The v1 path segment is plural, unlike the legacy `/api/suppression`.
+ assert str(rec.request.url) == "http://localhost/api/v1/suppressions"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {"email": "spam@x.com", "reason": "COMPLAINT"}
+ # v1 answers a bare body — nothing is unwrapped out of a {success, data} envelope.
+ assert result == SUPPRESSION_V1
+
+
+def test_get_v1_percent_encodes_the_address_so_a_plus_stays_in_the_local_part():
+ rec = Recorder(json_response(200, SUPPRESSION_V1))
+ client = make_client(rec)
+
+ client.suppression.get_v1("user+tag@example.com")
+
+ # `+` must survive as %2B; an encoder that leaves it raw addresses a space instead.
+ assert str(rec.request.url) == "http://localhost/api/v1/suppressions/user%2Btag%40example.com"
+ assert rec.request.method == "GET"
+
+
+def test_get_v1_hands_back_the_whole_bare_body():
+ rec = Recorder(json_response(200, SUPPRESSION_V1))
+ client = make_client(rec)
+
+ assert client.suppression.get_v1("spam@x.com") == SUPPRESSION_V1
+
+
+def test_get_v1_on_an_unsuppressed_address_raises_the_definite_404():
+ rec = Recorder(
+ problem_response(
+ 404,
+ {
+ "type": "https://docs.sendly.now/errors/resource_not_found",
+ "title": "Not Found",
+ "status": 404,
+ "code": "resource_not_found",
+ "detail": "No suppression record for clean@example.com.",
+ },
+ )
+ )
+ client = make_client(rec)
+
+ with pytest.raises(SendlyNotFoundError):
+ client.suppression.get_v1("clean@example.com")
+
+
+def test_delete_v1_encodes_the_address_and_returns_the_acknowledgement():
+ rec = Recorder(json_response(200, {"email": "user+tag@example.com", "deleted": True}))
+ client = make_client(rec)
+
+ deleted = client.suppression.delete_v1("user+tag@example.com")
+
+ assert deleted == {"email": "user+tag@example.com", "deleted": True}
+ assert str(rec.request.url) == "http://localhost/api/v1/suppressions/user%2Btag%40example.com"
+ assert rec.request.method == "DELETE"
+
+
+def test_list_v1_serializes_the_cursor_params_and_the_reason_filter():
+ page = cursor_page([SUPPRESSION_V1])
+ rec = Recorder(json_response(200, page))
+ client = make_client(rec)
+
+ assert client.suppression.list_v1({"limit": 5, "reason": "HARD_BOUNCE"}) == page
+ url = str(rec.request.url)
+ assert url == "http://localhost/api/v1/suppressions?limit=5&reason=HARD_BOUNCE"
+
+
+def test_iter_list_v1_walks_every_page_and_keeps_the_filter():
+ rec = SequenceRecorder(
+ json_response(200, cursor_page([{"email": "one@x.com"}], next_cursor="cur_2")),
+ json_response(200, cursor_page([{"email": "two@x.com"}])),
+ )
+ client = make_client(rec)
+
+ emails = [s["email"] for s in client.suppression.iter_list_v1({"reason": "COMPLAINT"})]
+
+ assert emails == ["one@x.com", "two@x.com"]
+ assert rec.urls == [
+ "http://localhost/api/v1/suppressions?reason=COMPLAINT",
+ "http://localhost/api/v1/suppressions?reason=COMPLAINT&after=cur_2",
+ ]
diff --git a/tests/test_templates.py b/tests/test_templates.py
index ae4d6e4..fd6526f 100644
--- a/tests/test_templates.py
+++ b/tests/test_templates.py
@@ -2,10 +2,32 @@
from __future__ import annotations
+import json
+
import pytest
from sendly import SendlyConflictError
-from support import Recorder, json_response, make_client
+from support import (
+ Recorder,
+ SequenceRecorder,
+ cursor_page,
+ json_response,
+ make_client,
+ problem_response,
+)
+
+TEMPLATE_V1 = {
+ "id": "tpl_1",
+ "name": "Welcome",
+ "description": None,
+ "subject": "Welcome aboard",
+ "body": "hi
",
+ "from": "a@b.com",
+ "from_name": None,
+ "reply_to": None,
+ "email_category": "MARKETING",
+ "version": 1,
+}
def test_create_posts_templates():
@@ -17,20 +39,27 @@ def test_create_posts_templates():
"subject": "Welcome",
"body": "hi
",
"from": "a@b.com",
- "type": "MARKETING",
+ # `emailCategory` since 1.1. `type` said nothing about which of a
+ # template's several kinds it named.
+ "emailCategory": "MARKETING",
}
)
assert str(rec.request.url) == "http://localhost/api/templates"
+ assert json.loads(rec.request.content)["emailCategory"] == "MARKETING"
def test_list_serializes_cursor_and_limit():
rec = Recorder(json_response(200, {"success": True, "data": {"data": [], "total": 0}}))
client = make_client(rec)
- client.templates.list({"limit": 25, "cursor": "t_50", "type": "MARKETING"})
+ client.templates.list(
+ {"limit": 25, "cursor": "t_50", "emailCategory": "SELF_MANAGED_UNSUBSCRIBE"}
+ )
url = str(rec.request.url)
assert "limit=25" in url
assert "cursor=t_50" in url
- assert "type=MARKETING" in url
+ # `SELF_MANAGED_UNSUBSCRIBE` is the 1.1 name for the value once called `HEADLESS`.
+ assert "emailCategory=SELF_MANAGED_UNSUBSCRIBE" in url
+ assert "type=" not in url
def test_update_patches_template():
@@ -54,3 +83,102 @@ def test_delete_raises_conflict_on_409():
client = make_client(rec)
with pytest.raises(SendlyConflictError):
client.templates.delete("t_1")
+
+
+def test_create_v1_posts_the_bare_body_and_returns_the_bare_template():
+ rec = Recorder(json_response(201, TEMPLATE_V1))
+ client = make_client(rec)
+
+ result = client.templates.create_v1(
+ {
+ "name": "Welcome",
+ "subject": "Welcome aboard",
+ "body": "hi
",
+ "from": "a@b.com",
+ "email_category": "MARKETING",
+ }
+ )
+
+ assert str(rec.request.url) == "http://localhost/api/v1/templates"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content)["email_category"] == "MARKETING"
+ # v1 answers a bare body — nothing is unwrapped out of a {success, data} envelope.
+ assert result == TEMPLATE_V1
+
+
+def test_get_v1_hands_back_the_whole_bare_body():
+ rec = Recorder(json_response(200, TEMPLATE_V1))
+ client = make_client(rec)
+
+ result = client.templates.get_v1("tpl_1")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/templates/tpl_1"
+ assert rec.request.method == "GET"
+ assert result == TEMPLATE_V1
+
+
+def test_update_v1_patches_only_the_fields_sent():
+ rec = Recorder(json_response(200, TEMPLATE_V1))
+ client = make_client(rec)
+
+ client.templates.update_v1("tpl_1", {"email_category": "TRANSACTIONAL"})
+
+ assert str(rec.request.url) == "http://localhost/api/v1/templates/tpl_1"
+ assert rec.request.method == "PATCH"
+ assert json.loads(rec.request.content) == {"email_category": "TRANSACTIONAL"}
+
+
+def test_delete_v1_returns_the_acknowledgement_the_legacy_delete_discards():
+ rec = Recorder(json_response(200, {"id": "tpl_1", "deleted": True}))
+ client = make_client(rec)
+
+ assert client.templates.delete_v1("tpl_1") == {"id": "tpl_1", "deleted": True}
+ assert str(rec.request.url) == "http://localhost/api/v1/templates/tpl_1"
+ assert rec.request.method == "DELETE"
+
+
+def test_delete_v1_surfaces_the_rfc_9457_conflict_for_a_template_still_in_use():
+ rec = Recorder(
+ problem_response(
+ 409,
+ {
+ "type": "https://docs.sendly.now/errors/conflict",
+ "title": "Conflict",
+ "status": 409,
+ "code": "conflict",
+ "detail": "Template is referenced by 1 scheduled campaign.",
+ },
+ )
+ )
+ client = make_client(rec)
+
+ with pytest.raises(SendlyConflictError):
+ client.templates.delete_v1("tpl_1")
+
+
+def test_list_v1_serializes_the_cursor_params_and_the_snake_case_category_filter():
+ page = cursor_page([TEMPLATE_V1])
+ rec = Recorder(json_response(200, page))
+ client = make_client(rec)
+
+ assert client.templates.list_v1({"limit": 5, "search": "welcome"}) == page
+ url = str(rec.request.url)
+ assert url.startswith("http://localhost/api/v1/templates?")
+ assert "limit=5" in url
+ assert "search=welcome" in url
+
+
+def test_iter_list_v1_walks_every_page_and_keeps_the_filter():
+ rec = SequenceRecorder(
+ json_response(200, cursor_page([{"id": "tpl_1"}], next_cursor="cur_2")),
+ json_response(200, cursor_page([{"id": "tpl_2"}])),
+ )
+ client = make_client(rec)
+
+ ids = [t["id"] for t in client.templates.iter_list_v1({"email_category": "MARKETING"})]
+
+ assert ids == ["tpl_1", "tpl_2"]
+ assert rec.urls == [
+ "http://localhost/api/v1/templates?email_category=MARKETING",
+ "http://localhost/api/v1/templates?email_category=MARKETING&after=cur_2",
+ ]
diff --git a/tests/test_topics.py b/tests/test_topics.py
new file mode 100644
index 0000000..d1da95b
--- /dev/null
+++ b/tests/test_topics.py
@@ -0,0 +1,152 @@
+"""Topics resource tests (``/api/v1``)."""
+
+from __future__ import annotations
+
+import json
+
+from support import Recorder, SequenceRecorder, cursor_page, json_response, make_client
+
+TOPIC = {
+ "id": "top_1",
+ "key": "product_news",
+ "name": "Product news",
+ "description": None,
+ "default_opt_in": False,
+ "archived": False,
+ "subscribed_count": 12,
+ "unsubscribed_count": 3,
+ "created_at": "2026-01-01T00:00:00.000Z",
+}
+
+
+def topic_page(items: list[object], *, cursor: str | None = None) -> dict[str, object]:
+ """One page of the topics list envelope.
+
+ ``support.cursor_page`` under a local name. Through 1.0 this was its own
+ builder, because topics answered the next page under ``cursor`` where the
+ rest of v1 answers ``next_cursor`` -- and a local fixture is exactly how a
+ second dialect stays invisible, so it delegates now rather than repeating
+ the shape.
+ """
+ return cursor_page(items, next_cursor=cursor)
+
+
+def test_list_returns_the_bare_page_envelope_and_all():
+ page = topic_page([TOPIC])
+ rec = Recorder(json_response(200, page))
+ client = make_client(rec)
+
+ # v1 does not wrap, so the page itself comes back -- not its "data" list.
+ assert client.topics.list() == page
+ assert str(rec.request.url) == "http://localhost/api/v1/topics"
+
+
+def test_list_serializes_limit_after_and_include_archived():
+ rec = Recorder(json_response(200, topic_page([])))
+ client = make_client(rec)
+
+ client.topics.list({"limit": 10, "after": "cur_top", "include_archived": True})
+
+ url = str(rec.request.url)
+ assert "limit=10" in url
+ assert "after=cur_top" in url
+ assert "include_archived=true" in url or "include_archived=True" in url
+ # `cursor` was this endpoint's own parameter through 1.0 and is not one now.
+ assert "cursor=" not in url
+
+
+def test_create_posts_the_key_and_opt_in_default():
+ rec = Recorder(json_response(201, TOPIC))
+ client = make_client(rec)
+
+ result = client.topics.create(
+ {"key": "product_news", "name": "Product news", "default_opt_in": False}
+ )
+
+ assert str(rec.request.url) == "http://localhost/api/v1/topics"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {
+ "key": "product_news",
+ "name": "Product news",
+ "default_opt_in": False,
+ }
+ assert result == TOPIC
+
+
+def test_get_and_update_hit_the_id_path():
+ rec = Recorder(json_response(200, TOPIC))
+ client = make_client(rec)
+ assert client.topics.get("top_1") == TOPIC
+ assert str(rec.request.url) == "http://localhost/api/v1/topics/top_1"
+ assert rec.request.method == "GET"
+
+ # Archiving is the retire path -- there is no DELETE to exercise.
+ rec = Recorder(json_response(200, {**TOPIC, "archived": True}))
+ client = make_client(rec)
+ updated = client.topics.update("top_1", {"archived": True})
+ assert str(rec.request.url) == "http://localhost/api/v1/topics/top_1"
+ assert rec.request.method == "PATCH"
+ assert json.loads(rec.request.content) == {"archived": True}
+ assert updated["archived"] is True
+
+
+def test_set_subscription_parks_the_contact_at_pending():
+ rec = Recorder(
+ json_response(
+ 200,
+ {
+ "topic_id": "top_1",
+ "contact_id": "con_1",
+ "status": "pending",
+ "confirmed_at": None,
+ "confirmation_url": "https://sendly.now/c/tok_1",
+ },
+ )
+ )
+ client = make_client(rec)
+
+ subscription = client.topics.set_subscription(
+ "top_1", {"contact_id": "con_1", "subscribed": True}
+ )
+
+ assert str(rec.request.url) == "http://localhost/api/v1/topics/top_1/subscriptions"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {"contact_id": "con_1", "subscribed": True}
+ # Asking to subscribe starts a double opt-in; it does not subscribe anyone.
+ assert subscription["status"] == "pending"
+ assert subscription["confirmation_url"] == "https://sendly.now/c/tok_1"
+
+
+def test_iter_list_walks_every_page_and_stops():
+ rec = SequenceRecorder(
+ json_response(200, topic_page([{"id": "top_1"}], cursor="cur_2")),
+ json_response(200, topic_page([{"id": "top_2"}, {"id": "top_3"}])),
+ )
+ client = make_client(rec)
+
+ assert [t["id"] for t in client.topics.iter_list()] == ["top_1", "top_2", "top_3"]
+ assert len(rec.requests) == 2
+
+
+def test_iter_list_follows_after_the_one_v1_page_parameter():
+ rec = SequenceRecorder(
+ json_response(200, topic_page([{"id": "top_1"}], cursor="cur_2")),
+ json_response(200, topic_page([{"id": "top_2"}])),
+ )
+ client = make_client(rec)
+
+ assert [t["id"] for t in client.topics.iter_list({"limit": 1})] == ["top_1", "top_2"]
+ assert rec.urls == [
+ "http://localhost/api/v1/topics?limit=1",
+ "http://localhost/api/v1/topics?limit=1&after=cur_2",
+ ]
+
+
+def test_iter_list_stops_when_a_page_repeats_the_cursor_it_was_handed():
+ # SequenceRecorder fails the test on a third request, so a walk that ignored
+ # the repeat would surface as an error rather than an infinite loop.
+ rec = SequenceRecorder(json_response(200, topic_page([{"id": "top_1"}], cursor="cur_stuck")))
+ client = make_client(rec)
+
+ assert [t["id"] for t in client.topics.iter_list({"after": "cur_stuck"})] == ["top_1"]
+ assert len(rec.requests) == 1
diff --git a/tests/test_validation.py b/tests/test_validation.py
new file mode 100644
index 0000000..241cb73
--- /dev/null
+++ b/tests/test_validation.py
@@ -0,0 +1,162 @@
+"""Email validation 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,
+)
+
+
+def validation(email: str, verdict: str) -> dict[str, object]:
+ """One address's verdict, with the evidence behind it."""
+ return {
+ "email": email,
+ "verdict": verdict,
+ "is_disposable": False,
+ "is_role_address": False,
+ "is_personal": True,
+ "has_mx_records": verdict != "undeliverable",
+ "reasons": [],
+ "contact_id": None,
+ }
+
+
+def results_page(items: list[dict[str, object]], *, cursor: str | None = None) -> dict[str, object]:
+ """One page of a run's results.
+
+ ``support.cursor_page`` under a local name. Through 1.0 this was its own
+ builder, because the endpoint named the next page ``cursor`` rather than
+ ``next_cursor`` -- and a local fixture is exactly how a second dialect stays
+ invisible, so it delegates now rather than repeating the shape.
+ """
+ return cursor_page(items, next_cursor=cursor)
+
+
+def test_validate_emails_posts_the_batch():
+ batch = {"results": [validation("a@example.com", "deliverable")]}
+ rec = Recorder(json_response(200, batch))
+ client = make_client(rec)
+
+ assert client.validation.validate_emails({"emails": ["a@example.com"]}) == batch
+ assert str(rec.request.url) == "http://localhost/api/v1/email-validations"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {"emails": ["a@example.com"]}
+
+
+def test_unknown_is_carried_through_as_its_own_verdict():
+ # A DNS timeout is `unknown`, never `undeliverable`: acting on the two
+ # together deletes live contacts over a network hiccup.
+ rec = Recorder(
+ json_response(
+ 200,
+ {
+ "results": [
+ validation("timeout@example.com", "unknown"),
+ validation("nope@example.com", "undeliverable"),
+ ]
+ },
+ )
+ )
+ client = make_client(rec)
+
+ batch = client.validation.validate_emails(
+ {"emails": ["timeout@example.com", "nope@example.com"]}
+ )
+
+ assert [r["verdict"] for r in batch["results"]] == ["unknown", "undeliverable"]
+
+
+def test_a_batch_over_the_fifty_address_ceiling_raises_the_validation_error():
+ rec = Recorder(
+ problem_response(
+ 422,
+ {
+ "type": "https://docs.sendly.now/errors/validation_error",
+ "title": "Validation Error",
+ "status": 422,
+ "code": "validation_error",
+ "detail": "`emails` must contain at most 50 items.",
+ "errors": [
+ {"pointer": "/emails", "code": "too_many_items", "message": "at most 50"}
+ ],
+ },
+ )
+ )
+ client = make_client(rec)
+
+ with pytest.raises(SendlyValidationError) as caught:
+ client.validation.validate_emails({"emails": [f"u{i}@example.com" for i in range(51)]})
+ assert caught.value.field_errors[0]["pointer"] == "/emails"
+
+
+def test_get_run_hits_the_run_id_path():
+ run = {"id": "vrun_1", "status": "running", "processed_count": 12}
+ rec = Recorder(json_response(200, run))
+ client = make_client(rec)
+
+ assert client.validation.get_run("vrun_1") == run
+ assert str(rec.request.url) == "http://localhost/api/v1/validation-runs/vrun_1"
+ assert rec.request.method == "GET"
+
+
+def test_list_results_serializes_limit_verdict_and_the_after_parameter():
+ page = results_page([validation("a@example.com", "undeliverable")])
+ rec = Recorder(json_response(200, page))
+ client = make_client(rec)
+
+ # Returned as-is: the envelope, not its `data` array.
+ assert (
+ client.validation.list_results(
+ "vrun_1", {"limit": 50, "verdict": "undeliverable", "after": "cur_1"}
+ )
+ == page
+ )
+ url = str(rec.request.url)
+ assert url == (
+ "http://localhost/api/v1/validation-runs/vrun_1/results"
+ "?limit=50&verdict=undeliverable&after=cur_1"
+ )
+ assert "cursor=" not in url
+
+
+def test_iter_list_results_pages_on_after_the_one_v1_page_parameter():
+ rec = SequenceRecorder(
+ json_response(
+ 200, results_page([validation("a@example.com", "undeliverable")], cursor="cur_2")
+ ),
+ json_response(200, results_page([validation("b@example.com", "undeliverable")])),
+ )
+ client = make_client(rec)
+
+ emails = [
+ r["email"]
+ for r in client.validation.iter_list_results("vrun_1", {"verdict": "undeliverable"})
+ ]
+
+ assert emails == ["a@example.com", "b@example.com"]
+ assert rec.urls == [
+ "http://localhost/api/v1/validation-runs/vrun_1/results?verdict=undeliverable",
+ "http://localhost/api/v1/validation-runs/vrun_1/results?verdict=undeliverable&after=cur_2",
+ ]
+
+
+def test_iter_list_results_stops_on_the_last_page():
+ # SequenceRecorder fails the test on an extra request, so a generator that
+ # ignored `has_more` would error rather than loop.
+ rec = SequenceRecorder(
+ json_response(200, results_page([validation("a@example.com", "deliverable")]))
+ )
+ client = make_client(rec)
+
+ assert [r["email"] for r in client.validation.iter_list_results("vrun_1")] == ["a@example.com"]
+ assert len(rec.requests) == 1
diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py
index 8fff935..fb478ba 100644
--- a/tests/test_webhooks.py
+++ b/tests/test_webhooks.py
@@ -2,10 +2,25 @@
from __future__ import annotations
+import json
+
import pytest
from sendly import SendlyRateLimitError
-from support import Recorder, json_response, make_client
+from support import (
+ Recorder,
+ SequenceRecorder,
+ cursor_page,
+ json_response,
+ make_client,
+)
+
+WEBHOOK_V1 = {
+ "id": "wh_1",
+ "url": "https://example.com/hook",
+ "event_types": ["email.delivered"],
+ "status": "ACTIVE",
+}
def test_create_posts_webhooks():
@@ -64,3 +79,108 @@ def test_delete_sends_delete():
client = make_client(rec)
client.webhooks.delete("w_1")
assert rec.request.method == "DELETE"
+
+
+def test_create_v1_posts_the_bare_body_and_hands_back_the_one_time_secret():
+ rec = Recorder(json_response(201, {"webhook": WEBHOOK_V1, "secret": "whsec_created"}))
+ client = make_client(rec)
+
+ created = client.webhooks.create_v1(
+ {"url": "https://example.com/hook", "event_types": ["email.delivered"]}
+ )
+
+ assert str(rec.request.url) == "http://localhost/api/v1/webhooks"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {
+ "url": "https://example.com/hook",
+ "event_types": ["email.delivered"],
+ }
+ # This response and rotate_secret_v1 are the only two that carry the secret.
+ assert created["secret"] == "whsec_created"
+ assert created["webhook"]["id"] == "wh_1"
+
+
+def test_v1_reads_return_the_bare_body_and_never_the_secret():
+ rec = Recorder(json_response(200, WEBHOOK_V1))
+ client = make_client(rec)
+
+ # A v1 body has no `data` key to unwrap, so unwrapping would lose the record.
+ fetched = client.webhooks.get_v1("wh_1")
+
+ assert fetched == WEBHOOK_V1
+ assert "secret" not in fetched
+ assert str(rec.request.url) == "http://localhost/api/v1/webhooks/wh_1"
+ assert rec.request.method == "GET"
+
+
+def test_update_v1_patches_with_the_replacement_event_list():
+ rec = Recorder(json_response(200, {**WEBHOOK_V1, "event_types": ["email.bounced"]}))
+ client = make_client(rec)
+
+ updated = client.webhooks.update_v1(
+ "wh_1", {"event_types": ["email.bounced"], "status": "ACTIVE"}
+ )
+
+ assert str(rec.request.url) == "http://localhost/api/v1/webhooks/wh_1"
+ assert rec.request.method == "PATCH"
+ assert json.loads(rec.request.content) == {
+ "event_types": ["email.bounced"],
+ "status": "ACTIVE",
+ }
+ # `event_types` replaces rather than merges -- `email.delivered` is gone.
+ assert updated["event_types"] == ["email.bounced"]
+
+
+def test_delete_v1_returns_the_deletion_receipt():
+ rec = Recorder(json_response(200, {"id": "wh_1", "deleted": True}))
+ client = make_client(rec)
+
+ assert client.webhooks.delete_v1("wh_1") == {"id": "wh_1", "deleted": True}
+ assert str(rec.request.url) == "http://localhost/api/v1/webhooks/wh_1"
+ assert rec.request.method == "DELETE"
+
+
+def test_rotate_secret_v1_posts_the_rotate_subpath_and_names_the_overlap_deadline():
+ rec = Recorder(
+ json_response(
+ 200,
+ {
+ "secret": "whsec_rotated",
+ "previous_secret_expires_at": "2026-09-06T00:00:00.000Z",
+ },
+ )
+ )
+ client = make_client(rec)
+
+ rotated = client.webhooks.rotate_secret_v1("wh_1")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/webhooks/wh_1/rotate-secret"
+ assert rec.request.method == "POST"
+ assert rotated["secret"] == "whsec_rotated"
+ # Both signatures ship until this moment; after it the old secret is rejected.
+ assert rotated["previous_secret_expires_at"] == "2026-09-06T00:00:00.000Z"
+
+
+def test_list_v1_serializes_the_cursor_query():
+ page = cursor_page([WEBHOOK_V1])
+ rec = Recorder(json_response(200, page))
+ client = make_client(rec)
+
+ assert client.webhooks.list_v1({"limit": 25, "after": "cur_wh"}) == page
+ assert str(rec.request.url) == "http://localhost/api/v1/webhooks?limit=25&after=cur_wh"
+
+
+def test_iter_list_v1_walks_every_page():
+ rec = SequenceRecorder(
+ json_response(200, cursor_page([{"id": "wh_1"}], next_cursor="cur_2")),
+ json_response(200, cursor_page([{"id": "wh_2"}, {"id": "wh_3"}])),
+ )
+ client = make_client(rec)
+
+ ids = [w["id"] for w in client.webhooks.iter_list_v1({"limit": 1})]
+
+ assert ids == ["wh_1", "wh_2", "wh_3"]
+ assert rec.urls == [
+ "http://localhost/api/v1/webhooks?limit=1",
+ "http://localhost/api/v1/webhooks?limit=1&after=cur_2",
+ ]
diff --git a/tests/test_workflows.py b/tests/test_workflows.py
index 191ce6b..7f8a54a 100644
--- a/tests/test_workflows.py
+++ b/tests/test_workflows.py
@@ -6,7 +6,7 @@
import pytest
-from sendly import SendlyNotFoundError
+from sendly import SendlyConflictError, SendlyNotFoundError
from support import (
Recorder,
SequenceRecorder,
@@ -157,3 +157,111 @@ def test_iter_list_executions_keeps_the_workflow_id_and_filter_across_pages():
"http://localhost/api/v1/workflows/wf_1/executions?status=RUNNING",
"http://localhost/api/v1/workflows/wf_1/executions?status=RUNNING&after=cur_2",
]
+
+
+TRIGGER_STEP = {
+ "id": "st_1",
+ "name": "Signed up",
+ "position": {"x": 0, "y": 0},
+ "type": "TRIGGER",
+ "config": {"eventName": "signup"},
+}
+
+GRAPH = {
+ "workflow_id": "wf_1",
+ "version": 7,
+ "steps": [{**TRIGGER_STEP, "template_id": None}],
+ "transitions": [],
+}
+
+
+def test_get_graph_reads_the_graph_subpath_and_returns_the_bare_body():
+ rec = Recorder(json_response(200, GRAPH))
+ client = make_client(rec)
+
+ graph = client.workflows.get_graph("wf_1")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/workflows/wf_1/graph"
+ assert rec.request.method == "GET"
+ # A v1 body arrives bare: `version` sits at the top level, not under `data`.
+ assert graph == GRAPH
+ assert graph["version"] == 7
+
+
+def test_replace_graph_issues_a_put_not_a_patch_and_sends_the_whole_document():
+ rec = Recorder(json_response(200, {**GRAPH, "version": 8}))
+ client = make_client(rec)
+
+ body = {"steps": [TRIGGER_STEP], "transitions": []}
+ graph = client.workflows.replace_graph("wf_1", body)
+
+ assert str(rec.request.url) == "http://localhost/api/v1/workflows/wf_1/graph"
+ assert rec.request.method == "PUT"
+ assert rec.request.method != "PATCH"
+ assert json.loads(rec.request.content) == body
+ assert graph["version"] == 8
+
+
+def test_replace_graph_raises_conflict_while_executions_are_running():
+ rec = Recorder(
+ problem_response(
+ 409,
+ {
+ "type": "https://docs.sendly.now/errors/conflict",
+ "title": "Conflict",
+ "status": 409,
+ "detail": "Workflow has running executions.",
+ "code": "conflict",
+ },
+ )
+ )
+ client = make_client(rec)
+
+ with pytest.raises(SendlyConflictError) as caught:
+ client.workflows.replace_graph("wf_1", {"steps": [TRIGGER_STEP], "transitions": []})
+ assert caught.value.error_code == "conflict"
+
+
+def test_clone_posts_the_name_and_returns_a_copy_that_is_always_disabled():
+ rec = Recorder(json_response(201, {"id": "wf_2", "name": "Onboarding v2", "enabled": False}))
+ client = make_client(rec)
+
+ copy = client.workflows.clone("wf_1", {"name": "Onboarding v2"})
+
+ assert str(rec.request.url) == "http://localhost/api/v1/workflows/wf_1/clone"
+ assert rec.request.method == "POST"
+ assert json.loads(rec.request.content) == {"name": "Onboarding v2"}
+ assert copy["id"] == "wf_2"
+ assert copy["enabled"] is False
+
+
+def test_pause_reports_the_runs_it_cancelled():
+ rec = Recorder(json_response(200, {"workflow": {"id": "wf_1"}, "cancelled_executions": 23}))
+ client = make_client(rec)
+
+ paused = client.workflows.pause("wf_1")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/workflows/wf_1/pause"
+ assert rec.request.method == "POST"
+ # This count is what makes pause different from update({"enabled": False}).
+ assert paused["cancelled_executions"] == 23
+
+
+def test_resume_reports_zero_cancellations():
+ rec = Recorder(json_response(200, {"workflow": {"id": "wf_1"}, "cancelled_executions": 0}))
+ client = make_client(rec)
+
+ resumed = client.workflows.resume("wf_1")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/workflows/wf_1/resume"
+ assert rec.request.method == "POST"
+ assert resumed["cancelled_executions"] == 0
+
+
+def test_graph_routes_percent_encode_the_workflow_id():
+ rec = Recorder(json_response(200, GRAPH))
+ client = make_client(rec)
+
+ client.workflows.get_graph("wf/../evil")
+
+ assert str(rec.request.url) == "http://localhost/api/v1/workflows/wf%2F..%2Fevil/graph"